Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43c75c8e36 | ||
|
|
5704531f3a | ||
|
|
87819780c9 | ||
|
|
3b88b9cde9 | ||
|
|
439edf827c | ||
|
|
24fa66281c | ||
|
|
99fbad3e97 | ||
|
|
dcdab3cff7 | ||
|
|
1cdce616e4 | ||
|
|
212607fc82 | ||
|
|
0cf0a41a60 | ||
|
|
f863004c8d |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 926 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -436,7 +436,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1010501;
|
CURRENT_PROJECT_VERSION = 1020101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.5;
|
MARKETING_VERSION = 1.2.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1010501;
|
CURRENT_PROJECT_VERSION = 1020101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.5;
|
MARKETING_VERSION = 1.2.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -741,7 +741,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1010501;
|
CURRENT_PROJECT_VERSION = 1020101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.5;
|
MARKETING_VERSION = 1.2.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 642 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_header_background@1x.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_header_background@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_header_background@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_sort_icon.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_sort_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_sort_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 588 B |
|
After Width: | Height: | Size: 970 B |
@@ -70,10 +70,10 @@ enum AuthSessionHelper {
|
|||||||
AppStore.shared.session.userId = String(user.businessUserId)
|
AppStore.shared.session.userId = String(user.businessUserId)
|
||||||
AppStore.shared.session.accountType = .scenicUser
|
AppStore.shared.session.accountType = .scenicUser
|
||||||
AppStore.shared.session.accountDisplayName = firstNonEmpty(
|
AppStore.shared.session.accountDisplayName = firstNonEmpty(
|
||||||
user.scenicName, user.nickname, user.realName, user.username
|
user.scenicName, user.nickname, user.realName
|
||||||
)
|
)
|
||||||
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName)
|
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
|
||||||
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username)
|
AppStore.shared.session.realName = user.realName
|
||||||
AppStore.shared.session.phone = user.phone
|
AppStore.shared.session.phone = user.phone
|
||||||
AppStore.shared.session.currentScenicId = user.scenicId
|
AppStore.shared.session.currentScenicId = user.scenicId
|
||||||
AppStore.shared.session.currentScenicName = user.scenicName
|
AppStore.shared.session.currentScenicName = user.scenicName
|
||||||
@@ -90,10 +90,10 @@ enum AuthSessionHelper {
|
|||||||
AppStore.shared.session.userId = String(user.businessUserId)
|
AppStore.shared.session.userId = String(user.businessUserId)
|
||||||
AppStore.shared.session.accountType = .storeUser
|
AppStore.shared.session.accountType = .storeUser
|
||||||
AppStore.shared.session.accountDisplayName = firstNonEmpty(
|
AppStore.shared.session.accountDisplayName = firstNonEmpty(
|
||||||
user.storeName, user.scenicName, user.realName, user.userName, user.username
|
user.storeName, user.scenicName, user.realName
|
||||||
)
|
)
|
||||||
AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName)
|
AppStore.shared.session.userName = user.realName
|
||||||
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username)
|
AppStore.shared.session.realName = user.realName
|
||||||
AppStore.shared.session.phone = user.phone
|
AppStore.shared.session.phone = user.phone
|
||||||
AppStore.shared.session.avatar = user.avatar
|
AppStore.shared.session.avatar = user.avatar
|
||||||
AppStore.shared.session.currentScenicId = user.scenicId
|
AppStore.shared.session.currentScenicId = user.scenicId
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
|||||||
let userId: Int
|
let userId: Int
|
||||||
let scenicUserId: Int
|
let scenicUserId: Int
|
||||||
let ssUserId: Int
|
let ssUserId: Int
|
||||||
let username: String
|
|
||||||
let realName: String
|
let realName: String
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let phone: String
|
let phone: String
|
||||||
@@ -212,7 +211,7 @@ struct V9ScenicUser: Decodable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username
|
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
|
||||||
}
|
}
|
||||||
|
|
||||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||||
@@ -238,8 +237,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
|||||||
case userId = "user_id"
|
case userId = "user_id"
|
||||||
case scenicUserId = "scenic_user_id"
|
case scenicUserId = "scenic_user_id"
|
||||||
case ssUserId = "ss_user_id"
|
case ssUserId = "ss_user_id"
|
||||||
case username = "user_name"
|
|
||||||
case legacyUsername = "username"
|
|
||||||
case realName = "real_name"
|
case realName = "real_name"
|
||||||
case nickname
|
case nickname
|
||||||
case phone
|
case phone
|
||||||
@@ -258,9 +255,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
|||||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||||
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
|
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
|
||||||
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
|
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
|
||||||
let preferredUsername = try container.decodeLossyString(forKey: .username)
|
|
||||||
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
|
|
||||||
username = preferredUsername.nonEmpty ?? legacyUsername
|
|
||||||
realName = try container.decodeLossyString(forKey: .realName)
|
realName = try container.decodeLossyString(forKey: .realName)
|
||||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||||
phone = try container.decodeLossyString(forKey: .phone)
|
phone = try container.decodeLossyString(forKey: .phone)
|
||||||
@@ -281,8 +275,6 @@ struct V9StoreUser: Decodable, Equatable {
|
|||||||
let id: Int
|
let id: Int
|
||||||
let userId: Int
|
let userId: Int
|
||||||
let storeUserId: Int
|
let storeUserId: Int
|
||||||
let username: String
|
|
||||||
let userName: String
|
|
||||||
let realName: String
|
let realName: String
|
||||||
let phone: String
|
let phone: String
|
||||||
let avatar: String
|
let avatar: String
|
||||||
@@ -300,14 +292,14 @@ struct V9StoreUser: Decodable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username
|
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
|
||||||
}
|
}
|
||||||
|
|
||||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||||
AccountSwitchAccount(
|
AccountSwitchAccount(
|
||||||
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
||||||
businessUserId: businessUserId,
|
businessUserId: businessUserId,
|
||||||
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号",
|
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
|
||||||
subtitle: joinAccountSubtitle(scenicName, roleName),
|
subtitle: joinAccountSubtitle(scenicName, roleName),
|
||||||
phone: phone,
|
phone: phone,
|
||||||
realName: realName,
|
realName: realName,
|
||||||
@@ -325,8 +317,6 @@ struct V9StoreUser: Decodable, Equatable {
|
|||||||
case id
|
case id
|
||||||
case userId = "user_id"
|
case userId = "user_id"
|
||||||
case storeUserId = "store_user_id"
|
case storeUserId = "store_user_id"
|
||||||
case username
|
|
||||||
case userName = "user_name"
|
|
||||||
case realName = "real_name"
|
case realName = "real_name"
|
||||||
case phone
|
case phone
|
||||||
case avatar
|
case avatar
|
||||||
@@ -346,8 +336,6 @@ struct V9StoreUser: Decodable, Equatable {
|
|||||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||||
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
|
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
|
||||||
username = try container.decodeLossyString(forKey: .username)
|
|
||||||
userName = try container.decodeLossyString(forKey: .userName)
|
|
||||||
realName = try container.decodeLossyString(forKey: .realName)
|
realName = try container.decodeLossyString(forKey: .realName)
|
||||||
phone = try container.decodeLossyString(forKey: .phone)
|
phone = try container.decodeLossyString(forKey: .phone)
|
||||||
avatar = try container.decodeLossyString(forKey: .avatar)
|
avatar = try container.decodeLossyString(forKey: .avatar)
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
|
|||||||
|
|
||||||
/// 收款页当前账号信息的展示文本格式化规则。
|
/// 收款页当前账号信息的展示文本格式化规则。
|
||||||
enum PaymentAccountDisplayFormatter {
|
enum PaymentAccountDisplayFormatter {
|
||||||
/// 解析当前登录摄影师名称,昵称为空时使用实名,仍为空时显示占位符。
|
/// 解析当前登录摄影师的真实姓名,空值显示占位符。
|
||||||
static func photographerName(userName: String, realName: String) -> String {
|
static func photographerName(realName: String) -> String {
|
||||||
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-"
|
realName.trimmedNonEmpty ?? "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 去除空值与重复项后,合并当前账号的全部店铺名称。
|
/// 去除空值与重复项后,合并当前账号的全部店铺名称。
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
|
|
||||||
private var staticPayURL = ""
|
private var staticPayURL = ""
|
||||||
private var dynamicPayURL = ""
|
private var dynamicPayURL = ""
|
||||||
|
private var hasCompletedPayCodeRequest = false
|
||||||
private let appStore: AppStore
|
private let appStore: AppStore
|
||||||
private let payPageConfigCache: PayPageConfigCaching
|
private let payPageConfigCache: PayPageConfigCaching
|
||||||
|
|
||||||
@@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
qrImage != nil && !staticPayURL.isEmpty
|
qrImage != nil && !staticPayURL.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
|
||||||
|
var isPayCodeLoading: Bool {
|
||||||
|
loading || !hasCompletedPayCodeRequest
|
||||||
|
}
|
||||||
|
|
||||||
var displayScenicName: String {
|
var displayScenicName: String {
|
||||||
scenicName.isEmpty ? "暂无景区" : scenicName
|
scenicName.isEmpty ? "暂无景区" : scenicName
|
||||||
}
|
}
|
||||||
@@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
|
|
||||||
var displayPhotographerName: String {
|
var displayPhotographerName: String {
|
||||||
PaymentAccountDisplayFormatter.photographerName(
|
PaymentAccountDisplayFormatter.photographerName(
|
||||||
userName: appStore.session.userName,
|
|
||||||
realName: appStore.session.realName
|
realName: appStore.session.realName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
|
|
||||||
func loadPayCode(api: PaymentAPI) async {
|
func loadPayCode(api: PaymentAPI) async {
|
||||||
guard !loading else { return }
|
guard !loading else { return }
|
||||||
refreshLocalState()
|
|
||||||
loading = true
|
loading = true
|
||||||
notifyStateChange()
|
refreshLocalState()
|
||||||
defer {
|
defer {
|
||||||
loading = false
|
loading = false
|
||||||
|
hasCompletedPayCodeRequest = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ final class ProfileViewModel {
|
|||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
var displayNickname: String {
|
var displayNickname: String {
|
||||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayRealName: String {
|
var displayRealName: String {
|
||||||
nonEmpty(userInfo?.realName) ?? "--"
|
nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayPhone: String {
|
var displayPhone: String {
|
||||||
@@ -37,6 +37,17 @@ final class ProfileViewModel {
|
|||||||
return uid.isEmpty ? "--" : uid
|
return uid.isEmpty ? "--" : uid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 本地是否已有可供冷启动首屏展示的基本资料。
|
||||||
|
var hasCachedBasicInfo: Bool {
|
||||||
|
[
|
||||||
|
AppStore.shared.session.userName,
|
||||||
|
AppStore.shared.session.realName,
|
||||||
|
AppStore.shared.session.phone,
|
||||||
|
AppStore.shared.session.avatar,
|
||||||
|
AppStore.shared.session.userId,
|
||||||
|
].contains { nonEmpty($0) != nil }
|
||||||
|
}
|
||||||
|
|
||||||
var accountDisplayName: String {
|
var accountDisplayName: String {
|
||||||
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return name.isEmpty ? "--" : name
|
return name.isEmpty ? "--" : name
|
||||||
@@ -112,6 +123,7 @@ final class ProfileViewModel {
|
|||||||
if !info.roleName.isEmpty {
|
if !info.roleName.isEmpty {
|
||||||
AppStore.shared.session.roleName = info.roleName
|
AppStore.shared.session.roleName = info.roleName
|
||||||
}
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
if showPhotographerFields {
|
if showPhotographerFields {
|
||||||
async let realName = api.realNameInfo()
|
async let realName = api.realNameInfo()
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ protocol TravelAlbumServing {
|
|||||||
|
|
||||||
/// 拉取相册小程序码。
|
/// 拉取相册小程序码。
|
||||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
||||||
|
|
||||||
|
/// 拉取当前景区可用的 AI 修图模板。
|
||||||
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||||
|
|
||||||
|
/// 提交相册素材 AI 修图任务。
|
||||||
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图任务。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -155,6 +164,31 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取当前景区可用的 AI 修图模板。
|
||||||
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/ai-retouch-templates",
|
||||||
|
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交相册素材 AI 修图任务;服务端 data 内容无需客户端消费。
|
||||||
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册 ID 请求体。
|
/// 旅拍相册 ID 请求体。
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
let fileSize: Int
|
let fileSize: Int
|
||||||
let coverUrl: String
|
let coverUrl: String
|
||||||
let isPurchased: Bool
|
let isPurchased: Bool
|
||||||
|
let aiRetouchStatus: Int
|
||||||
|
let aiRetouchStatusName: String
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let aiRefinedURL: String
|
||||||
|
let aiAtmosphereURL: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
let updatedAt: String
|
let updatedAt: String
|
||||||
|
|
||||||
@@ -142,10 +147,42 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
case fileSize = "file_size"
|
case fileSize = "file_size"
|
||||||
case coverUrl = "cover_url"
|
case coverUrl = "cover_url"
|
||||||
case isPurchased = "is_purchased"
|
case isPurchased = "is_purchased"
|
||||||
|
case aiRetouchStatus = "ai_retouch_status"
|
||||||
|
case aiRetouchStatusName = "ai_retouch_status_name"
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case aiRefinedURL = "ai_refined_url"
|
||||||
|
case aiAtmosphereURL = "ai_atmosphere_url"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从素材接口解码;AI 修图扩展字段缺失、为空或类型异常时使用安全默认值。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(Int.self, forKey: .id)
|
||||||
|
userEquityTravelId = try container.decode(Int.self, forKey: .userEquityTravelId)
|
||||||
|
status = try container.decode(Int.self, forKey: .status)
|
||||||
|
orderNumber = try container.decode(String.self, forKey: .orderNumber)
|
||||||
|
userId = try container.decode(Int.self, forKey: .userId)
|
||||||
|
fileName = try container.decode(String.self, forKey: .fileName)
|
||||||
|
fileType = try container.decode(Int.self, forKey: .fileType)
|
||||||
|
fileUrl = try container.decode(String.self, forKey: .fileUrl)
|
||||||
|
fileSize = try container.decode(Int.self, forKey: .fileSize)
|
||||||
|
coverUrl = try container.decode(String.self, forKey: .coverUrl)
|
||||||
|
isPurchased = try container.decode(Bool.self, forKey: .isPurchased)
|
||||||
|
aiRetouchStatus = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchStatus)) ?? 0
|
||||||
|
aiRetouchStatusName = (
|
||||||
|
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
|
||||||
|
) ?? ""
|
||||||
|
aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0
|
||||||
|
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
|
||||||
|
aiAtmosphereURL = (
|
||||||
|
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
|
||||||
|
) ?? ""
|
||||||
|
createdAt = try container.decode(String.self, forKey: .createdAt)
|
||||||
|
updatedAt = try container.decode(String.self, forKey: .updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: Int = 0,
|
id: Int = 0,
|
||||||
userEquityTravelId: Int = 0,
|
userEquityTravelId: Int = 0,
|
||||||
@@ -158,6 +195,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
fileSize: Int = 0,
|
fileSize: Int = 0,
|
||||||
coverUrl: String = "",
|
coverUrl: String = "",
|
||||||
isPurchased: Bool = false,
|
isPurchased: Bool = false,
|
||||||
|
aiRetouchStatus: Int = 0,
|
||||||
|
aiRetouchStatusName: String = "",
|
||||||
|
aiRetouchBatchId: Int = 0,
|
||||||
|
aiRefinedURL: String = "",
|
||||||
|
aiAtmosphereURL: String = "",
|
||||||
createdAt: String = "",
|
createdAt: String = "",
|
||||||
updatedAt: String = ""
|
updatedAt: String = ""
|
||||||
) {
|
) {
|
||||||
@@ -172,11 +214,69 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
self.fileSize = fileSize
|
self.fileSize = fileSize
|
||||||
self.coverUrl = coverUrl
|
self.coverUrl = coverUrl
|
||||||
self.isPurchased = isPurchased
|
self.isPurchased = isPurchased
|
||||||
|
self.aiRetouchStatus = aiRetouchStatus
|
||||||
|
self.aiRetouchStatusName = aiRetouchStatusName
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
|
self.aiRefinedURL = aiRefinedURL
|
||||||
|
self.aiAtmosphereURL = aiAtmosphereURL
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
|
||||||
|
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||||
|
case purchased
|
||||||
|
case pending
|
||||||
|
case processing
|
||||||
|
case retouched
|
||||||
|
case cover
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册素材网格角标展示内容。
|
||||||
|
struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
||||||
|
let kind: TravelAlbumMaterialBadgeKind
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumMaterial {
|
||||||
|
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
|
||||||
|
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
|
if aiRetouchStatus == 0 {
|
||||||
|
return isPurchased
|
||||||
|
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
switch aiRetouchStatus {
|
||||||
|
case 1:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .pending,
|
||||||
|
text: statusName.isEmpty ? "待处理" : statusName
|
||||||
|
)
|
||||||
|
case 2:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .processing,
|
||||||
|
text: statusName.isEmpty ? "修图中" : statusName
|
||||||
|
)
|
||||||
|
case 3:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: statusName == "AI封面" ? .cover : .retouched,
|
||||||
|
text: statusName.isEmpty ? "AI已修" : statusName
|
||||||
|
)
|
||||||
|
case 4:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .failed,
|
||||||
|
text: statusName.isEmpty ? "失败" : statusName
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
|
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
|
||||||
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||||
let name: String
|
let name: String
|
||||||
@@ -247,6 +347,147 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
|
||||||
|
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
|
||||||
|
case refined
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
|
||||||
|
/// 模板分组展示标题。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "原图精修"
|
||||||
|
case .atmosphere: "氛围感修图"
|
||||||
|
case .cover: "封面风格模板"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。
|
||||||
|
enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
||||||
|
/// 对一个相册内的原始素材发起首次 AI 修图。
|
||||||
|
case initial(albumId: Int, materialIds: [Int])
|
||||||
|
/// 对单张素材的既有 AI 结果发起覆盖重修。
|
||||||
|
case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType)
|
||||||
|
|
||||||
|
/// 当前页面需要展示的模板分组。
|
||||||
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
|
switch self {
|
||||||
|
case .initial(_, let materialIds):
|
||||||
|
var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere]
|
||||||
|
if materialIds.count >= 4 { categories.append(.cover) }
|
||||||
|
return categories
|
||||||
|
case .reretouch(_, _, .refined):
|
||||||
|
return [.refined]
|
||||||
|
case .reretouch(_, _, .atmosphere):
|
||||||
|
return [.atmosphere]
|
||||||
|
case .reretouch(_, _, .all):
|
||||||
|
return [.refined, .atmosphere]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
if case .initial = self, category == .atmosphere { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工作流目标是否满足接口的最小参数要求。
|
||||||
|
var isValid: Bool {
|
||||||
|
switch self {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
return albumId > 0 && !materialIds.isEmpty
|
||||||
|
case .reretouch(let materialId, let batchId, _):
|
||||||
|
return materialId > 0 && batchId > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
|
||||||
|
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let previewURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case previewURL = "preview_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建 AI 修图模板。
|
||||||
|
init(id: Int, name: String, previewURL: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.previewURL = previewURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板接口响应,按原图、氛围感和封面风格分组。
|
||||||
|
struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable {
|
||||||
|
let refinedTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let coverTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case refinedTemplates = "refined_templates"
|
||||||
|
case atmosphereTemplates = "atmosphere_templates"
|
||||||
|
case coverTemplates = "cover_templates"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建分组模板响应,默认各组为空。
|
||||||
|
init(
|
||||||
|
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
coverTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
) {
|
||||||
|
self.refinedTemplates = refinedTemplates
|
||||||
|
self.atmosphereTemplates = atmosphereTemplates
|
||||||
|
self.coverTemplates = coverTemplates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交 AI 修图任务的请求参数。
|
||||||
|
struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let materialIds: [Int]
|
||||||
|
let refinedTemplateId: Int
|
||||||
|
let atmosphereTemplateId: Int?
|
||||||
|
let coverTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case materialIds = "material_ids"
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
|
case coverTemplateId = "cover_template_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。
|
||||||
|
enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable {
|
||||||
|
case refined = 1
|
||||||
|
case atmosphere = 2
|
||||||
|
case all = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图的最小请求参数。
|
||||||
|
struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let type: TravelAlbumAIReretouchType
|
||||||
|
let refinedTemplateId: Int?
|
||||||
|
let atmosphereTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case type
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 旅拍相册展示格式化工具。
|
/// 旅拍相册展示格式化工具。
|
||||||
enum TravelAlbumDisplayFormatter {
|
enum TravelAlbumDisplayFormatter {
|
||||||
/// 脱敏手机号。
|
/// 脱敏手机号。
|
||||||
@@ -277,4 +518,22 @@ enum TravelAlbumDisplayFormatter {
|
|||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将服务端创建时间格式化为摘要卡使用的 `yyyy/MM/dd HH:mm`。
|
||||||
|
static func creationTimeText(_ text: String) -> String {
|
||||||
|
guard !text.isEmpty else { return "--" }
|
||||||
|
let normalized = text.replacingOccurrences(of: "T", with: " ")
|
||||||
|
guard normalized.count >= 16 else { return normalized }
|
||||||
|
return String(normalized.prefix(16)).replacingOccurrences(of: "-", with: "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算相册摘要卡封面,按相册、素材缩略图、素材原图顺序回退。
|
||||||
|
static func albumCoverURL(album: TravelAlbum?, materials: [TravelAlbumMaterial]) -> String {
|
||||||
|
if let coverURL = album?.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines), !coverURL.isEmpty {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
guard let first = materials.first else { return "" }
|
||||||
|
let materialCover = first.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return materialCover.isEmpty ? first.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) : materialCover
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumPreviewModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
||||||
|
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
||||||
|
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
||||||
|
case projectsOnly
|
||||||
|
/// 横滑依次切换当前项目的关联图,并在边界进入相邻项目原图。
|
||||||
|
case includeVariants
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册预览页配置。
|
||||||
|
struct TravelAlbumPreviewConfiguration: Sendable {
|
||||||
|
let swipeMode: TravelAlbumPreviewSwipeMode
|
||||||
|
|
||||||
|
/// 创建预览配置,默认只按原图项目分页。
|
||||||
|
init(swipeMode: TravelAlbumPreviewSwipeMode = .projectsOnly) {
|
||||||
|
self.swipeMode = swipeMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览图片类型,顺序同时决定 Tab 与关联图浏览顺序。
|
||||||
|
enum TravelAlbumPreviewAssetKind: Int, CaseIterable, Sendable, Hashable {
|
||||||
|
case original
|
||||||
|
case retouched
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .original: "原图"
|
||||||
|
case .retouched: "精修后"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页使用的单张图片信息,与后端关联图字段解耦。
|
||||||
|
struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let kind: TravelAlbumPreviewAssetKind
|
||||||
|
let fileURL: String
|
||||||
|
let coverURL: String
|
||||||
|
let fileName: String
|
||||||
|
let fileSize: Int
|
||||||
|
|
||||||
|
/// 实际用于展示的地址,优先使用原图地址。
|
||||||
|
var displayURL: String {
|
||||||
|
let trimmedFileURL = fileURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmedFileURL.isEmpty ? coverURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedFileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 横滑预览地址,优先使用体积更小的封面图以降低解码开销。
|
||||||
|
var previewURL: String {
|
||||||
|
let trimmedCoverURL = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmedCoverURL.isEmpty ? fileURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedCoverURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一张原图及其所有关联图片组成的预览项目。
|
||||||
|
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||||
|
let originalMaterialId: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let assets: [TravelAlbumPreviewAsset]
|
||||||
|
|
||||||
|
var id: Int { originalMaterialId }
|
||||||
|
|
||||||
|
/// 按产品约定顺序返回实际存在的图片。
|
||||||
|
var orderedAssets: [TravelAlbumPreviewAsset] {
|
||||||
|
TravelAlbumPreviewAssetKind.allCases.compactMap(asset(for:))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否存在原图之外的关联图片。
|
||||||
|
var hasVariants: Bool {
|
||||||
|
orderedAssets.contains { $0.kind != .original }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定类型的图片。
|
||||||
|
func asset(for kind: TravelAlbumPreviewAssetKind) -> TravelAlbumPreviewAsset? {
|
||||||
|
assets.first { $0.kind == kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||||
|
init(material: TravelAlbumMaterial) {
|
||||||
|
originalMaterialId = material.id
|
||||||
|
aiRetouchBatchId = material.aiRetouchBatchId
|
||||||
|
var mappedAssets = [
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "original-\(material.id)",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: material.fileUrl,
|
||||||
|
coverURL: material.coverUrl,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let refinedURL = material.aiRefinedURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !refinedURL.isEmpty {
|
||||||
|
mappedAssets.append(
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "retouched-\(material.id)",
|
||||||
|
kind: .retouched,
|
||||||
|
fileURL: refinedURL,
|
||||||
|
coverURL: refinedURL,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let atmosphereURL = material.aiAtmosphereURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !atmosphereURL.isEmpty {
|
||||||
|
mappedAssets.append(
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "atmosphere-\(material.id)",
|
||||||
|
kind: .atmosphere,
|
||||||
|
fileURL: atmosphereURL,
|
||||||
|
coverURL: atmosphereURL,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assets = mappedAssets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建包含关联图片的项目,主要供适配器和测试使用。
|
||||||
|
init(originalMaterialId: Int, aiRetouchBatchId: Int = 0, assets: [TravelAlbumPreviewAsset]) {
|
||||||
|
self.originalMaterialId = originalMaterialId
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
|
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
|
||||||
|
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。
|
||||||
|
func aiRetouchWorkflow(
|
||||||
|
albumId: Int,
|
||||||
|
selectedKind: TravelAlbumPreviewAssetKind
|
||||||
|
) -> TravelAlbumAIRetouchWorkflow? {
|
||||||
|
guard hasVariants else {
|
||||||
|
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
||||||
|
}
|
||||||
|
switch selectedKind {
|
||||||
|
case .original:
|
||||||
|
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
||||||
|
case .retouched:
|
||||||
|
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined)
|
||||||
|
case .atmosphere:
|
||||||
|
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere)
|
||||||
|
case .cover:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览分页节点,记录当前图片所属项目及类型。
|
||||||
|
struct TravelAlbumPreviewNode: Sendable, Hashable {
|
||||||
|
let projectIndex: Int
|
||||||
|
let kind: TravelAlbumPreviewAssetKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页纯状态工具,负责构建节点和处理分组滑动边界。
|
||||||
|
enum TravelAlbumPreviewNavigator {
|
||||||
|
/// 根据滑动模式生成页面节点。
|
||||||
|
static func nodes(
|
||||||
|
projects: [TravelAlbumPreviewProject],
|
||||||
|
mode: TravelAlbumPreviewSwipeMode
|
||||||
|
) -> [TravelAlbumPreviewNode] {
|
||||||
|
projects.enumerated().flatMap { index, project in
|
||||||
|
switch mode {
|
||||||
|
case .projectsOnly:
|
||||||
|
[TravelAlbumPreviewNode(projectIndex: index, kind: .original)]
|
||||||
|
case .includeVariants:
|
||||||
|
project.orderedAssets.map { TravelAlbumPreviewNode(projectIndex: index, kind: $0.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理反向滑动边界:从项目原图向右滑时直接进入上一项目原图。
|
||||||
|
static func backwardTargetIndex(
|
||||||
|
nodes: [TravelAlbumPreviewNode],
|
||||||
|
currentIndex: Int
|
||||||
|
) -> Int {
|
||||||
|
guard nodes.indices.contains(currentIndex), currentIndex > 0 else { return max(0, currentIndex) }
|
||||||
|
let current = nodes[currentIndex]
|
||||||
|
guard current.kind == .original, current.projectIndex > 0 else { return currentIndex - 1 }
|
||||||
|
return nodes.firstIndex {
|
||||||
|
$0.projectIndex == current.projectIndex - 1 && $0.kind == .original
|
||||||
|
} ?? currentIndex - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回删除当前项目后应展示的项目索引;优先保持原索引以显示下一张,末项则回退上一张。
|
||||||
|
static func projectIndexAfterDeletion(
|
||||||
|
deletedProjectIndex: Int,
|
||||||
|
remainingProjectCount: Int
|
||||||
|
) -> Int? {
|
||||||
|
guard remainingProjectCount > 0 else { return nil }
|
||||||
|
return min(max(0, deletedProjectIndex), remainingProjectCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。
|
||||||
|
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
|
||||||
|
case success(String?)
|
||||||
|
case failure(String)
|
||||||
|
case unavailable(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页删除操作协议,以原素材项目 ID 为目标。
|
||||||
|
protocol TravelAlbumPreviewActionHandling: AnyObject {
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
|
||||||
|
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
|
.unavailable("项目删除接口待接入")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumAIRetouchTemplateViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。
|
||||||
|
final class TravelAlbumAIRetouchTemplateViewModel {
|
||||||
|
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var coverTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var selectedRefinedTemplateId: Int?
|
||||||
|
private(set) var selectedAtmosphereTemplateId: Int?
|
||||||
|
private(set) var selectedCoverTemplateId: Int?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isSubmitting = false
|
||||||
|
private(set) var loadErrorMessage: String?
|
||||||
|
|
||||||
|
let scenicId: Int
|
||||||
|
let workflow: TravelAlbumAIRetouchWorkflow
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onSubmitted: (() -> Void)?
|
||||||
|
|
||||||
|
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
||||||
|
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||||
|
self.init(
|
||||||
|
scenicId: scenicId,
|
||||||
|
workflow: .initial(albumId: albumId, materialIds: materialIds)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用明确工作流创建模板选择状态。
|
||||||
|
init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) {
|
||||||
|
self.scenicId = scenicId
|
||||||
|
switch workflow {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
self.workflow = .initial(
|
||||||
|
albumId: albumId,
|
||||||
|
materialIds: Array(Set(materialIds)).sorted()
|
||||||
|
)
|
||||||
|
case .reretouch:
|
||||||
|
self.workflow = workflow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前工作流需要展示的模板分组。
|
||||||
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
|
workflow.visibleCategories
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前分组是否为选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
workflow.isOptional(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前必选模板或业务参数缺失时用于底部提示的文案。
|
||||||
|
var validationMessage: String? {
|
||||||
|
guard !isLoading, loadErrorMessage == nil else { return nil }
|
||||||
|
guard workflow.isValid else {
|
||||||
|
if case .reretouch = workflow {
|
||||||
|
return "当前图片缺少修图批次,请刷新后重试"
|
||||||
|
}
|
||||||
|
return "请选择要修图的照片"
|
||||||
|
}
|
||||||
|
for category in visibleCategories where !isOptional(category) {
|
||||||
|
if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil {
|
||||||
|
return unavailableMessage(for: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否满足提交条件。
|
||||||
|
var canSubmit: Bool {
|
||||||
|
!isLoading
|
||||||
|
&& !isSubmitting
|
||||||
|
&& loadErrorMessage == nil
|
||||||
|
&& workflow.isValid
|
||||||
|
&& validationMessage == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实时拉取当前景区模板并按工作流设置默认选择。
|
||||||
|
func loadTemplates(api: any TravelAlbumServing) async {
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
loadErrorMessage = "请先选择景区"
|
||||||
|
notifyStateChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
loadErrorMessage = nil
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
|
||||||
|
refinedTemplates = response.refinedTemplates
|
||||||
|
atmosphereTemplates = response.atmosphereTemplates
|
||||||
|
coverTemplates = response.coverTemplates
|
||||||
|
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
|
||||||
|
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
||||||
|
? atmosphereTemplates.first?.id
|
||||||
|
: nil
|
||||||
|
selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
refinedTemplates = []
|
||||||
|
atmosphereTemplates = []
|
||||||
|
coverTemplates = []
|
||||||
|
selectedRefinedTemplateId = nil
|
||||||
|
selectedAtmosphereTemplateId = nil
|
||||||
|
selectedCoverTemplateId = nil
|
||||||
|
isLoading = false
|
||||||
|
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定分组的模板。
|
||||||
|
func templates(for category: TravelAlbumAIRetouchTemplateCategory) -> [TravelAlbumAIRetouchTemplate] {
|
||||||
|
switch category {
|
||||||
|
case .refined: refinedTemplates
|
||||||
|
case .atmosphere: atmosphereTemplates
|
||||||
|
case .cover: coverTemplates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定分组当前选中的模板 ID。
|
||||||
|
func selectedTemplateId(for category: TravelAlbumAIRetouchTemplateCategory) -> Int? {
|
||||||
|
switch category {
|
||||||
|
case .refined: selectedRefinedTemplateId
|
||||||
|
case .atmosphere: selectedAtmosphereTemplateId
|
||||||
|
case .cover: selectedCoverTemplateId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
|
||||||
|
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
||||||
|
guard visibleCategories.contains(category),
|
||||||
|
templates(for: category).contains(where: { $0.id == id })
|
||||||
|
else { return }
|
||||||
|
switch category {
|
||||||
|
case .refined:
|
||||||
|
selectedRefinedTemplateId = id
|
||||||
|
case .atmosphere:
|
||||||
|
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
||||||
|
case .cover:
|
||||||
|
selectedCoverTemplateId = id
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按当前工作流提交首次修图或覆盖重修任务。
|
||||||
|
func submit(api: any TravelAlbumServing) async {
|
||||||
|
guard !isSubmitting else { return }
|
||||||
|
guard canSubmit else {
|
||||||
|
onShowMessage?(validationMessage ?? "当前无法提交AI修图")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isSubmitting = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
switch workflow {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||||
|
onShowMessage?("请选择原图精修模板")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await api.submitAIRetouch(
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
materialIds: materialIds,
|
||||||
|
refinedTemplateId: refinedTemplateId,
|
||||||
|
atmosphereTemplateId: selectedAtmosphereTemplateId,
|
||||||
|
coverTemplateId: selectedCoverTemplateId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
case .reretouch(let materialId, let batchId, let type):
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: materialId,
|
||||||
|
aiRetouchBatchId: batchId,
|
||||||
|
type: type,
|
||||||
|
refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId,
|
||||||
|
atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSubmitted?()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "AI修图任务提交失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String {
|
||||||
|
switch category {
|
||||||
|
case .refined: "暂无可用的原图精修模板"
|
||||||
|
case .atmosphere: "暂无可用的氛围感修图模板"
|
||||||
|
case .cover: "暂无可用的封面风格模板"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,11 +27,21 @@ final class TravelAlbumDetailViewModel {
|
|||||||
case .fileNameDesc: "文件名倒序"
|
case .fileNameDesc: "文件名倒序"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var compactTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .createdAsc: "时间 ↑"
|
||||||
|
case .createdDesc: "时间 ↓"
|
||||||
|
case .fileNameAsc: "名称 ↑"
|
||||||
|
case .fileNameDesc: "名称 ↓"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var album: TravelAlbum?
|
private(set) var album: TravelAlbum?
|
||||||
private(set) var materials: [TravelAlbumMaterial] = []
|
private(set) var materials: [TravelAlbumMaterial] = []
|
||||||
private(set) var allPhotoCount = 0
|
private(set) var allPhotoCount = 0
|
||||||
|
private(set) var purchasedPhotoCount = 0
|
||||||
private(set) var selectedTab: Tab = .all
|
private(set) var selectedTab: Tab = .all
|
||||||
private(set) var sortOption: SortOption = .createdDesc
|
private(set) var sortOption: SortOption = .createdDesc
|
||||||
private(set) var isLoading = true
|
private(set) var isLoading = true
|
||||||
@@ -65,6 +75,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadAlbumInfo(api: api)
|
await loadAlbumInfo(api: api)
|
||||||
await loadAllPhotoCount(api: api)
|
await loadAllPhotoCount(api: api)
|
||||||
|
await loadPurchasedPhotoCount(api: api)
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -102,6 +113,35 @@ final class TravelAlbumDetailViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取已购照片数量;失败时保留已有数量,不影响素材列表加载。
|
||||||
|
func loadPurchasedPhotoCount(api: any TravelAlbumServing) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.materialList(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
orderBy: sortOption.rawValue,
|
||||||
|
isPurchased: 1
|
||||||
|
)
|
||||||
|
purchasedPhotoCount = response.total
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要卡封面地址,依次回退相册封面、首张素材封面和首张素材原图。
|
||||||
|
var displayCoverURL: String {
|
||||||
|
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: materials)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前 Tab 对应的原图项目总数,供全屏预览索引使用。
|
||||||
|
var currentPhotoCount: Int {
|
||||||
|
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换素材 tab。
|
/// 切换素材 tab。
|
||||||
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||||
guard selectedTab != tab else { return }
|
guard selectedTab != tab else { return }
|
||||||
@@ -144,6 +184,8 @@ final class TravelAlbumDetailViewModel {
|
|||||||
canLoadMore = materials.count < response.total
|
canLoadMore = materials.count < response.total
|
||||||
if selectedTab == .all {
|
if selectedTab == .all {
|
||||||
allPhotoCount = response.total
|
allPhotoCount = response.total
|
||||||
|
} else {
|
||||||
|
purchasedPhotoCount = response.total
|
||||||
}
|
}
|
||||||
isLoadingMore = false
|
isLoadingMore = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
@@ -165,6 +207,43 @@ final class TravelAlbumDetailViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 重新拉取当前筛选、排序下已经加载的分页范围,供全屏预览刷新关联图片。
|
||||||
|
func reloadLoadedMaterials(
|
||||||
|
api: any TravelAlbumServing
|
||||||
|
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
|
||||||
|
let requestedPageCount = max(currentPage, 1)
|
||||||
|
var refreshed: [TravelAlbumMaterial] = []
|
||||||
|
var refreshedTotal = 0
|
||||||
|
var loadedPageCount = 0
|
||||||
|
|
||||||
|
for page in 1 ... requestedPageCount {
|
||||||
|
let response = try await api.materialList(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderBy: sortOption.rawValue,
|
||||||
|
isPurchased: selectedTab == .purchased ? 1 : nil
|
||||||
|
)
|
||||||
|
if page == 1 { refreshedTotal = response.total }
|
||||||
|
refreshed.append(contentsOf: response.list)
|
||||||
|
loadedPageCount = page
|
||||||
|
if refreshed.count >= refreshedTotal || response.list.isEmpty { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = Set<Int>()
|
||||||
|
materials = refreshed.filter { seen.insert($0.id).inserted }
|
||||||
|
currentPage = max(loadedPageCount, 1)
|
||||||
|
canLoadMore = materials.count < refreshedTotal
|
||||||
|
selectedMaterialIds.formIntersection(materials.map(\.id))
|
||||||
|
if selectedTab == .all {
|
||||||
|
allPhotoCount = refreshedTotal
|
||||||
|
} else {
|
||||||
|
purchasedPhotoCount = refreshedTotal
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
return TravelAlbumListResponse(total: refreshedTotal, list: materials)
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换选择模式。
|
/// 切换选择模式。
|
||||||
func toggleSelectionMode() {
|
func toggleSelectionMode() {
|
||||||
guard selectedTab == .all else { return }
|
guard selectedTab == .all else { return }
|
||||||
@@ -190,6 +269,25 @@ final class TravelAlbumDetailViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||||
|
func completeAIRetouchSubmission() {
|
||||||
|
isSelectionMode = false
|
||||||
|
selectedMaterialIds = []
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
||||||
|
func removeMaterialAfterPreviewDeletion(id: Int) {
|
||||||
|
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
let material = materials.remove(at: index)
|
||||||
|
selectedMaterialIds.remove(id)
|
||||||
|
allPhotoCount = max(0, allPhotoCount - 1)
|
||||||
|
if material.isPurchased {
|
||||||
|
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除已选素材。
|
/// 删除已选素材。
|
||||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
let ids = Array(selectedMaterialIds)
|
let ids = Array(selectedMaterialIds)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumPreviewActionHandler.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 图片预览页业务操作实现,负责将底部操作转发到旅拍相册服务。
|
||||||
|
final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
|
||||||
|
/// 使用旅拍相册服务创建操作处理器。
|
||||||
|
init(api: any TravelAlbumServing) {
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
|
do {
|
||||||
|
try await api.deleteMaterial(id: originalMaterialId)
|
||||||
|
return .success("删除成功")
|
||||||
|
} catch is CancellationError {
|
||||||
|
return .failure("删除已取消")
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return .failure(message.isEmpty ? "删除失败" : message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import UIKit
|
|||||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
|
private var sessionExpiredDialog: SessionExpiredDialogViewController?
|
||||||
|
|
||||||
func scene(
|
func scene(
|
||||||
_ scene: UIScene,
|
_ scene: UIScene,
|
||||||
@@ -86,18 +87,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleSessionDidExpire() {
|
@objc private func handleSessionDidExpire() {
|
||||||
|
guard AppStore.shared.session.isLoggedIn else { return }
|
||||||
|
guard sessionExpiredDialog == nil else { return }
|
||||||
|
|
||||||
|
GlobalLoadingManager.shared.hideAll()
|
||||||
|
let dialog = SessionExpiredDialogViewController { [weak self] in
|
||||||
|
self?.transitionToLogin()
|
||||||
|
}
|
||||||
|
sessionExpiredDialog = dialog
|
||||||
|
|
||||||
|
guard let presenter = topViewController(from: window?.rootViewController) else {
|
||||||
|
sessionExpiredDialog = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
presenter.present(dialog, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transitionToLogin() {
|
||||||
|
sessionExpiredDialog = nil
|
||||||
PushNotificationManager.shared.handleLogout()
|
PushNotificationManager.shared.handleLogout()
|
||||||
AppStore.shared.logout()
|
AppStore.shared.logout()
|
||||||
AppRouter.setRoot(.login, on: window)
|
AppRouter.setRoot(.login, on: window)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleUserDidLogout() {
|
@objc private func handleUserDidLogout() {
|
||||||
PushNotificationManager.shared.handleLogout()
|
transitionToLogin()
|
||||||
AppStore.shared.logout()
|
|
||||||
AppRouter.setRoot(.login, on: window)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleUserDidLogin() {
|
@objc private func handleUserDidLogin() {
|
||||||
|
sessionExpiredDialog?.dismiss(animated: false)
|
||||||
|
sessionExpiredDialog = nil
|
||||||
AppRouter.setRoot(.mainTab, on: window)
|
AppRouter.setRoot(.mainTab, on: window)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
PushNotificationManager.shared.handleLoginCompleted()
|
PushNotificationManager.shared.handleLoginCompleted()
|
||||||
@@ -108,4 +127,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
@objc private func handleAccountDidSwitch() {
|
@objc private func handleAccountDidSwitch() {
|
||||||
PushNotificationManager.shared.handleAccountSwitched()
|
PushNotificationManager.shared.handleAccountSwitched()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
|
||||||
|
guard let viewController else { return nil }
|
||||||
|
if let presented = viewController.presentedViewController {
|
||||||
|
return topViewController(from: presented)
|
||||||
|
}
|
||||||
|
if let navigationController = viewController as? UINavigationController {
|
||||||
|
return topViewController(from: navigationController.visibleViewController)
|
||||||
|
}
|
||||||
|
if let tabBarController = viewController as? UITabBarController {
|
||||||
|
return topViewController(from: tabBarController.selectedViewController)
|
||||||
|
}
|
||||||
|
return viewController
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//
|
||||||
|
// SessionExpiredDialogViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 登录凭证失效时展示的强制确认弹窗,用户确认后由调用方执行退出登录。
|
||||||
|
@MainActor
|
||||||
|
final class SessionExpiredDialogViewController: UIViewController {
|
||||||
|
|
||||||
|
private let onConfirm: () -> Void
|
||||||
|
private let confirmButton = AppButton(title: "确认并退出登录")
|
||||||
|
private var didConfirm = false
|
||||||
|
|
||||||
|
/// 创建不可通过点击遮罩关闭的登录失效弹窗。
|
||||||
|
init(onConfirm: @escaping () -> Void) {
|
||||||
|
self.onConfirm = onConfirm
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .overFullScreen
|
||||||
|
modalTransitionStyle = .crossDissolve
|
||||||
|
isModalInPresentation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
||||||
|
view.accessibilityViewIsModal = true
|
||||||
|
view.accessibilityIdentifier = "sessionExpired.dialog"
|
||||||
|
|
||||||
|
let cardView = UIView()
|
||||||
|
cardView.backgroundColor = AppColor.cardBackground
|
||||||
|
cardView.layer.cornerRadius = AppRadius.lg
|
||||||
|
cardView.clipsToBounds = true
|
||||||
|
|
||||||
|
let warningImageView = UIImageView(
|
||||||
|
image: UIImage(systemName: "exclamationmark.circle.fill")
|
||||||
|
)
|
||||||
|
warningImageView.tintColor = UIColor(hex: 0xF05A5A)
|
||||||
|
warningImageView.contentMode = .scaleAspectFit
|
||||||
|
warningImageView.isAccessibilityElement = true
|
||||||
|
warningImageView.accessibilityLabel = "登录状态异常"
|
||||||
|
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = "登录提醒"
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
let messageLabel = UILabel()
|
||||||
|
messageLabel.text = "当前用户已在其他设备登录,\n请重新登录"
|
||||||
|
messageLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
|
messageLabel.font = .systemFont(ofSize: 14)
|
||||||
|
messageLabel.textAlignment = .center
|
||||||
|
messageLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
confirmButton.accessibilityIdentifier = "sessionExpired.confirmButton"
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
let contentStack = UIStackView(arrangedSubviews: [
|
||||||
|
warningImageView,
|
||||||
|
titleLabel,
|
||||||
|
messageLabel,
|
||||||
|
confirmButton,
|
||||||
|
])
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.alignment = .fill
|
||||||
|
contentStack.spacing = 16
|
||||||
|
contentStack.setCustomSpacing(4, after: titleLabel)
|
||||||
|
contentStack.setCustomSpacing(20, after: messageLabel)
|
||||||
|
|
||||||
|
view.addSubview(cardView)
|
||||||
|
cardView.addSubview(contentStack)
|
||||||
|
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(32)
|
||||||
|
}
|
||||||
|
contentStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
warningImageView.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
guard !didConfirm else { return }
|
||||||
|
didConfirm = true
|
||||||
|
confirmButton.isEnabled = false
|
||||||
|
onConfirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
private let qrContainerView = UIView()
|
private let qrContainerView = UIView()
|
||||||
private let qrImageView = UIImageView()
|
private let qrImageView = UIImageView()
|
||||||
private let qrPlaceholderView = UIView()
|
private let qrPlaceholderView = UIView()
|
||||||
|
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
private let qrErrorLabel = UILabel()
|
private let qrErrorLabel = UILabel()
|
||||||
private let refreshButton = UIButton(type: .system)
|
private let refreshButton = UIButton(type: .system)
|
||||||
private let setAmountButton = UIButton(type: .system)
|
private let setAmountButton = UIButton(type: .system)
|
||||||
@@ -127,6 +128,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
|
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
|
||||||
qrPlaceholderView.isHidden = true
|
qrPlaceholderView.isHidden = true
|
||||||
|
|
||||||
|
qrLoadingIndicator.color = AppColor.primary
|
||||||
|
qrLoadingIndicator.hidesWhenStopped = true
|
||||||
|
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
|
||||||
|
|
||||||
qrErrorLabel.text = "未选景区或网络问题"
|
qrErrorLabel.text = "未选景区或网络问题"
|
||||||
qrErrorLabel.font = .systemFont(ofSize: 14)
|
qrErrorLabel.font = .systemFont(ofSize: 14)
|
||||||
qrErrorLabel.textColor = AppColor.danger
|
qrErrorLabel.textColor = AppColor.danger
|
||||||
@@ -150,6 +155,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
trailing: 24
|
trailing: 24
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
actionRow.alpha = 0
|
||||||
|
actionRow.isUserInteractionEnabled = false
|
||||||
|
actionRow.accessibilityElementsHidden = true
|
||||||
|
|
||||||
configureLinkButton(setAmountButton, title: "设置金额")
|
configureLinkButton(setAmountButton, title: "设置金额")
|
||||||
configureLinkButton(saveQRButton, title: "保存二维码")
|
configureLinkButton(saveQRButton, title: "保存二维码")
|
||||||
@@ -206,6 +214,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
|
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
|
||||||
qrCardView.addSubview(qrContentStack)
|
qrCardView.addSubview(qrContentStack)
|
||||||
|
|
||||||
|
qrPlaceholderView.addSubview(qrLoadingIndicator)
|
||||||
qrPlaceholderView.addSubview(qrErrorLabel)
|
qrPlaceholderView.addSubview(qrErrorLabel)
|
||||||
qrPlaceholderView.addSubview(refreshButton)
|
qrPlaceholderView.addSubview(refreshButton)
|
||||||
|
|
||||||
@@ -275,6 +284,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
qrPlaceholderView.snp.makeConstraints { make in
|
qrPlaceholderView.snp.makeConstraints { make in
|
||||||
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
|
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
|
||||||
}
|
}
|
||||||
|
qrLoadingIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
qrErrorLabel.snp.makeConstraints { make in
|
qrErrorLabel.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(56)
|
make.top.equalToSuperview().offset(56)
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||||||
@@ -370,9 +382,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
private func loadData() async {
|
private func loadData() async {
|
||||||
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
|
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
|
||||||
showLoading()
|
|
||||||
await viewModel.loadPayCode(api: paymentAPI)
|
await viewModel.loadPayCode(api: paymentAPI)
|
||||||
hideLoading()
|
|
||||||
await pageConfig
|
await pageConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,17 +397,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
|
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
|
||||||
applyBrandConfigIfNeeded()
|
applyBrandConfigIfNeeded()
|
||||||
|
|
||||||
if let image = viewModel.qrImage {
|
if viewModel.isPayCodeLoading {
|
||||||
|
qrImageView.isHidden = true
|
||||||
|
qrContainerView.isHidden = true
|
||||||
|
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
|
||||||
|
? .white
|
||||||
|
: AppColor.inputBackground
|
||||||
|
qrPlaceholderView.isHidden = false
|
||||||
|
qrErrorLabel.isHidden = true
|
||||||
|
refreshButton.isHidden = true
|
||||||
|
qrLoadingIndicator.startAnimating()
|
||||||
|
setActionRowAvailable(false)
|
||||||
|
} else if let image = viewModel.qrImage {
|
||||||
qrImageView.image = image
|
qrImageView.image = image
|
||||||
qrImageView.isHidden = false
|
qrImageView.isHidden = false
|
||||||
qrContainerView.isHidden = !viewModel.usesNalatiBranding
|
qrContainerView.isHidden = !viewModel.usesNalatiBranding
|
||||||
qrPlaceholderView.isHidden = true
|
qrPlaceholderView.isHidden = true
|
||||||
actionRow.isHidden = false
|
qrLoadingIndicator.stopAnimating()
|
||||||
|
setActionRowAvailable(true)
|
||||||
} else {
|
} else {
|
||||||
qrImageView.isHidden = true
|
qrImageView.isHidden = true
|
||||||
qrContainerView.isHidden = true
|
qrContainerView.isHidden = true
|
||||||
|
qrPlaceholderView.backgroundColor = AppColor.inputBackground
|
||||||
qrPlaceholderView.isHidden = false
|
qrPlaceholderView.isHidden = false
|
||||||
actionRow.isHidden = true
|
qrErrorLabel.isHidden = false
|
||||||
|
refreshButton.isHidden = false
|
||||||
|
qrLoadingIndicator.stopAnimating()
|
||||||
|
setActionRowAvailable(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.showAmountDialog {
|
if viewModel.showAmountDialog {
|
||||||
@@ -408,6 +434,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setActionRowAvailable(_ isAvailable: Bool) {
|
||||||
|
actionRow.alpha = isAvailable ? 1 : 0
|
||||||
|
actionRow.isUserInteractionEnabled = isAvailable
|
||||||
|
actionRow.accessibilityElementsHidden = !isAvailable
|
||||||
|
}
|
||||||
|
|
||||||
private func presentAmountDialogIfNeeded() {
|
private func presentAmountDialogIfNeeded() {
|
||||||
guard amountDialog == nil else {
|
guard amountDialog == nil else {
|
||||||
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
|
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
|
||||||
@@ -625,9 +657,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
@objc private func refreshTapped() {
|
@objc private func refreshTapped() {
|
||||||
Task {
|
Task {
|
||||||
showLoading()
|
|
||||||
await viewModel.refresh(api: paymentAPI)
|
await viewModel.refresh(api: paymentAPI)
|
||||||
hideLoading()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ final class ProfileViewController: BaseViewController {
|
|||||||
title = "我的"
|
title = "我的"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||||
|
|
||||||
@@ -104,12 +109,10 @@ final class ProfileViewController: BaseViewController {
|
|||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
let showLoading = !hasLoadedProfileOnce
|
let showLoading = !hasLoadedProfileOnce && !viewModel.hasCachedBasicInfo
|
||||||
Task {
|
Task {
|
||||||
await reloadProfile(showGlobalLoading: showLoading)
|
await reloadProfile(showGlobalLoading: showLoading)
|
||||||
if showLoading {
|
hasLoadedProfileOnce = true
|
||||||
hasLoadedProfileOnce = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,597 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumAIRetouchTemplateViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
|
||||||
|
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||||
|
/// 页面使用的 collection section。
|
||||||
|
private enum Section: Hashable {
|
||||||
|
case templates(TravelAlbumAIRetouchTemplateCategory)
|
||||||
|
case mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页面使用的 diffable item。
|
||||||
|
private enum Item: Hashable {
|
||||||
|
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
|
||||||
|
case mode
|
||||||
|
}
|
||||||
|
|
||||||
|
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
private let onSubmitted: () -> Void
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
private let statusContainer = UIView()
|
||||||
|
private let statusIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let retryButton = UIButton(type: .system)
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let bottomDivider = UIView()
|
||||||
|
private let footerStack = UIStackView()
|
||||||
|
private let validationLabel = UILabel()
|
||||||
|
private let actionStack = UIStackView()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
/// 创建 AI 修图模板选择 Sheet。
|
||||||
|
init(
|
||||||
|
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
onSubmitted: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.api = api
|
||||||
|
self.onSubmitted = onSubmitted
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .pageSheet
|
||||||
|
configureSheetPresentation()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
|
||||||
|
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
|
||||||
|
|
||||||
|
titleLabel.text = "AI修图"
|
||||||
|
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.accessibilityTraits = .header
|
||||||
|
|
||||||
|
collectionView.backgroundColor = .clear
|
||||||
|
collectionView.showsVerticalScrollIndicator = false
|
||||||
|
collectionView.alwaysBounceVertical = true
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection"
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumAIRetouchTemplateCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
|
||||||
|
)
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumAIRetouchModeCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
|
||||||
|
)
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumAIRetouchSectionHeader.self,
|
||||||
|
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier
|
||||||
|
)
|
||||||
|
configureDataSource()
|
||||||
|
|
||||||
|
statusLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||||||
|
statusLabel.font = .systemFont(ofSize: 14)
|
||||||
|
statusLabel.textAlignment = .center
|
||||||
|
statusLabel.numberOfLines = 0
|
||||||
|
retryButton.setTitle("重试", for: .normal)
|
||||||
|
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton"
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar"
|
||||||
|
bottomDivider.backgroundColor = AIRetouchTemplateStyle.border
|
||||||
|
validationLabel.textColor = AIRetouchTemplateStyle.danger
|
||||||
|
validationLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
validationLabel.textAlignment = .center
|
||||||
|
validationLabel.numberOfLines = 0
|
||||||
|
validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel"
|
||||||
|
|
||||||
|
footerStack.axis = .vertical
|
||||||
|
footerStack.spacing = 8
|
||||||
|
footerStack.alignment = .fill
|
||||||
|
actionStack.axis = .horizontal
|
||||||
|
actionStack.spacing = 12
|
||||||
|
actionStack.distribution = .fillEqually
|
||||||
|
actionStack.alignment = .fill
|
||||||
|
configureCancelButton()
|
||||||
|
configureConfirmButton()
|
||||||
|
actionStack.addArrangedSubview(cancelButton)
|
||||||
|
actionStack.addArrangedSubview(confirmButton)
|
||||||
|
footerStack.addArrangedSubview(validationLabel)
|
||||||
|
footerStack.addArrangedSubview(actionStack)
|
||||||
|
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
view.addSubview(statusContainer)
|
||||||
|
statusContainer.addSubview(statusIndicator)
|
||||||
|
statusContainer.addSubview(statusLabel)
|
||||||
|
statusContainer.addSubview(retryButton)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(bottomDivider)
|
||||||
|
bottomBar.addSubview(footerStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(18)
|
||||||
|
make.height.equalTo(30)
|
||||||
|
}
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
bottomDivider.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(0.5)
|
||||||
|
}
|
||||||
|
footerStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(10)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(18)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
|
}
|
||||||
|
actionStack.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
statusContainer.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(collectionView)
|
||||||
|
}
|
||||||
|
statusIndicator.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview().offset(-32)
|
||||||
|
}
|
||||||
|
statusLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusIndicator.snp.bottom).offset(14)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(40)
|
||||||
|
}
|
||||||
|
retryButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusLabel.snp.bottom).offset(12)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.height.equalTo(36)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
viewModel.onSubmitted = { [weak self] in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
self.dismiss(animated: true, completion: self.onSubmitted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
|
Task { await viewModel.loadTemplates(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureSheetPresentation() {
|
||||||
|
guard let sheet = sheetPresentationController else { return }
|
||||||
|
sheet.detents = [.large()]
|
||||||
|
sheet.selectedDetentIdentifier = .large
|
||||||
|
sheet.prefersGrabberVisible = true
|
||||||
|
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureCancelButton() {
|
||||||
|
var configuration = UIButton.Configuration.filled()
|
||||||
|
configuration.title = "取消"
|
||||||
|
configuration.baseBackgroundColor = .white
|
||||||
|
configuration.baseForegroundColor = AIRetouchTemplateStyle.primary
|
||||||
|
configuration.background.cornerRadius = 14
|
||||||
|
configuration.background.strokeColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.55)
|
||||||
|
configuration.background.strokeWidth = 1
|
||||||
|
configuration.titleTextAttributesTransformer = buttonTitleTransformer
|
||||||
|
cancelButton.configuration = configuration
|
||||||
|
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureConfirmButton() {
|
||||||
|
var configuration = UIButton.Configuration.filled()
|
||||||
|
configuration.title = "确定"
|
||||||
|
configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary
|
||||||
|
configuration.baseForegroundColor = .white
|
||||||
|
configuration.background.cornerRadius = 14
|
||||||
|
configuration.titleTextAttributesTransformer = buttonTitleTransformer
|
||||||
|
confirmButton.configuration = configuration
|
||||||
|
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonTitleTransformer: UIConfigurationTextAttributesTransformer {
|
||||||
|
UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureDataSource() {
|
||||||
|
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||||||
|
[weak self] collectionView, indexPath, item in
|
||||||
|
guard let self else { return nil }
|
||||||
|
switch item {
|
||||||
|
case .template(let category, let template):
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumAIRetouchTemplateCell
|
||||||
|
cell.apply(
|
||||||
|
template: template,
|
||||||
|
selected: self.viewModel.selectedTemplateId(for: category) == template.id
|
||||||
|
)
|
||||||
|
return cell
|
||||||
|
case .mode:
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumAIRetouchModeCell
|
||||||
|
cell.apply()
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||||||
|
guard kind == UICollectionView.elementKindSectionHeader,
|
||||||
|
let self,
|
||||||
|
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section),
|
||||||
|
case .templates(let category) = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let header = collectionView.dequeueReusableSupplementaryView(
|
||||||
|
ofKind: kind,
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumAIRetouchSectionHeader
|
||||||
|
header.apply(title: category.title, optional: self.viewModel.isOptional(category))
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||||
|
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||||
|
guard let self,
|
||||||
|
self.dataSource != nil,
|
||||||
|
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
|
||||||
|
case .templates:
|
||||||
|
let itemSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .absolute(118),
|
||||||
|
heightDimension: .absolute(154)
|
||||||
|
)
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
|
||||||
|
section.interGroupSpacing = 12
|
||||||
|
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18)
|
||||||
|
section.boundarySupplementaryItems = [
|
||||||
|
NSCollectionLayoutBoundarySupplementaryItem(
|
||||||
|
layoutSize: NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .absolute(40)
|
||||||
|
),
|
||||||
|
elementKind: UICollectionView.elementKindSectionHeader,
|
||||||
|
alignment: .top
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return section
|
||||||
|
case .mode:
|
||||||
|
let itemSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .absolute(72)
|
||||||
|
)
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 18, bottom: 22, trailing: 18)
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
let isLoadFailure = viewModel.loadErrorMessage != nil
|
||||||
|
statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure
|
||||||
|
collectionView.isHidden = viewModel.isLoading || isLoadFailure
|
||||||
|
if viewModel.isLoading {
|
||||||
|
statusIndicator.startAnimating()
|
||||||
|
statusLabel.text = "正在加载修图模板…"
|
||||||
|
retryButton.isHidden = true
|
||||||
|
} else if let message = viewModel.loadErrorMessage {
|
||||||
|
statusIndicator.stopAnimating()
|
||||||
|
statusLabel.text = message
|
||||||
|
retryButton.isHidden = false
|
||||||
|
} else {
|
||||||
|
statusIndicator.stopAnimating()
|
||||||
|
statusLabel.text = nil
|
||||||
|
retryButton.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
validationLabel.text = viewModel.validationMessage
|
||||||
|
validationLabel.isHidden = viewModel.validationMessage == nil
|
||||||
|
cancelButton.isEnabled = !viewModel.isSubmitting
|
||||||
|
confirmButton.isEnabled = viewModel.canSubmit
|
||||||
|
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
|
||||||
|
var confirmConfiguration = confirmButton.configuration
|
||||||
|
confirmConfiguration?.title = viewModel.isSubmitting ? "提交中" : "确定"
|
||||||
|
confirmConfiguration?.showsActivityIndicator = viewModel.isSubmitting
|
||||||
|
confirmButton.configuration = confirmConfiguration
|
||||||
|
isModalInPresentation = viewModel.isSubmitting
|
||||||
|
applySnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySnapshot() {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
for category in viewModel.visibleCategories {
|
||||||
|
appendTemplates(category, to: &snapshot)
|
||||||
|
}
|
||||||
|
snapshot.appendSections([.mode])
|
||||||
|
snapshot.appendItems([.mode], toSection: .mode)
|
||||||
|
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendTemplates(
|
||||||
|
_ category: TravelAlbumAIRetouchTemplateCategory,
|
||||||
|
to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>
|
||||||
|
) {
|
||||||
|
let section = Section.templates(category)
|
||||||
|
snapshot.appendSections([section])
|
||||||
|
snapshot.appendItems(
|
||||||
|
viewModel.templates(for: category).map { Item.template(category, $0) },
|
||||||
|
toSection: section
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
guard !viewModel.isSubmitting else { return }
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
guard viewModel.canSubmit else {
|
||||||
|
if let message = viewModel.validationMessage { showToast(message) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { await viewModel.submit(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func retryTapped() {
|
||||||
|
Task { await viewModel.loadTemplates(api: api) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
|
case .template(let category, let template) = item else { return }
|
||||||
|
viewModel.toggleTemplate(id: template.id, category: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
|
||||||
|
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||||
|
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
|
||||||
|
|
||||||
|
private let previewImageView = UIImageView()
|
||||||
|
private let nameLabel = UILabel()
|
||||||
|
private let checkView = UIImageView()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = .white
|
||||||
|
contentView.layer.cornerRadius = 12
|
||||||
|
contentView.layer.borderWidth = 1
|
||||||
|
contentView.clipsToBounds = true
|
||||||
|
|
||||||
|
previewImageView.contentMode = .scaleAspectFill
|
||||||
|
previewImageView.clipsToBounds = true
|
||||||
|
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
|
||||||
|
nameLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
|
nameLabel.textAlignment = .center
|
||||||
|
nameLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
checkView.image = UIImage(systemName: "checkmark.circle.fill")
|
||||||
|
checkView.tintColor = AIRetouchTemplateStyle.primary
|
||||||
|
checkView.backgroundColor = .white
|
||||||
|
checkView.layer.cornerRadius = 10
|
||||||
|
|
||||||
|
contentView.addSubview(previewImageView)
|
||||||
|
contentView.addSubview(nameLabel)
|
||||||
|
contentView.addSubview(checkView)
|
||||||
|
previewImageView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(116)
|
||||||
|
}
|
||||||
|
nameLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(previewImageView.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(6)
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
checkView.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(8)
|
||||||
|
make.size.equalTo(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
previewImageView.kf.cancelDownloadTask()
|
||||||
|
previewImageView.image = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新模板内容与选中状态。
|
||||||
|
func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
|
||||||
|
nameLabel.text = template.name
|
||||||
|
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
|
||||||
|
previewImageView.contentMode = .scaleAspectFill
|
||||||
|
previewImageView.kf.setImage(
|
||||||
|
with: url,
|
||||||
|
placeholder: UIImage(systemName: "photo")?.withTintColor(
|
||||||
|
AIRetouchTemplateStyle.textSecondary,
|
||||||
|
renderingMode: .alwaysOriginal
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
previewImageView.image = UIImage(systemName: "photo")
|
||||||
|
previewImageView.tintColor = AIRetouchTemplateStyle.textSecondary
|
||||||
|
previewImageView.contentMode = .scaleAspectFit
|
||||||
|
}
|
||||||
|
contentView.layer.borderColor = (
|
||||||
|
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
|
||||||
|
).cgColor
|
||||||
|
contentView.layer.borderWidth = selected ? 2 : 1
|
||||||
|
checkView.isHidden = !selected
|
||||||
|
isSelected = selected
|
||||||
|
accessibilityLabel = template.name
|
||||||
|
accessibilityValue = selected ? "已选择" : "未选择"
|
||||||
|
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板分组标题,可附带“选填”标签。
|
||||||
|
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||||
|
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let optionalLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
|
optionalLabel.text = "选填"
|
||||||
|
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
|
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
||||||
|
optionalLabel.textAlignment = .center
|
||||||
|
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||||
|
optionalLabel.layer.cornerRadius = 8
|
||||||
|
optionalLabel.clipsToBounds = true
|
||||||
|
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(optionalLabel)
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(18)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
optionalLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||||
|
make.centerY.equalTo(titleLabel)
|
||||||
|
make.width.equalTo(38)
|
||||||
|
make.height.equalTo(20)
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新分组标题与选填标签。
|
||||||
|
func apply(title: String, optional: Bool) {
|
||||||
|
titleLabel.text = title
|
||||||
|
optionalLabel.isHidden = !optional
|
||||||
|
accessibilityLabel = optional ? "\(title),选填" : title
|
||||||
|
accessibilityTraits = .header
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模式卡片,展示计费方式与剩余张数占位。
|
||||||
|
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
|
||||||
|
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let remainingLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = .white
|
||||||
|
contentView.layer.cornerRadius = 14
|
||||||
|
contentView.layer.borderWidth = 1
|
||||||
|
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
|
||||||
|
|
||||||
|
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
|
remainingLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
|
||||||
|
remainingLabel.textColor = AIRetouchTemplateStyle.primary
|
||||||
|
remainingLabel.textAlignment = .right
|
||||||
|
|
||||||
|
contentView.addSubview(titleLabel)
|
||||||
|
contentView.addSubview(remainingLabel)
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
remainingLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用当前固定修图模式与额度占位文案。
|
||||||
|
func apply() {
|
||||||
|
titleLabel.text = "AI精修 按张收费"
|
||||||
|
remainingLabel.text = "剩余--张"
|
||||||
|
accessibilityIdentifier = "travelAlbum.aiRetouchModeCell"
|
||||||
|
accessibilityLabel = "AI精修,按张收费,剩余张数暂不可用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板页视觉常量。
|
||||||
|
private enum AIRetouchTemplateStyle {
|
||||||
|
static let primary = UIColor(hex: 0x1677FF)
|
||||||
|
static let pageBackground = UIColor(hex: 0xF7F9FC)
|
||||||
|
static let textPrimary = UIColor(hex: 0x172033)
|
||||||
|
static let textSecondary = UIColor(hex: 0x7F8A9E)
|
||||||
|
static let border = UIColor(hex: 0xDCE4EF)
|
||||||
|
static let danger = UIColor(hex: 0xE53935)
|
||||||
|
}
|
||||||
@@ -7,30 +7,41 @@ import Kingfisher
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 相册详情管理页,对齐 Android `TravelAlbumDetailScreen`。
|
/// 相册管理页,展示相册摘要、素材筛选排序、分页与批量操作。
|
||||||
final class TravelAlbumDetailViewController: BaseViewController {
|
final class TravelAlbumDetailViewController: BaseViewController {
|
||||||
private let viewModel: TravelAlbumDetailViewModel
|
private let viewModel: TravelAlbumDetailViewModel
|
||||||
private let api: any TravelAlbumServing
|
private let api: any TravelAlbumServing
|
||||||
|
private let previewConfiguration: TravelAlbumPreviewConfiguration
|
||||||
|
private let scenicIdProvider: () -> Int
|
||||||
|
|
||||||
private let scrollContainer = UIView()
|
private let contentView = UIView()
|
||||||
private let infoCard = TravelAlbumInfoCard()
|
private let infoCard = TravelAlbumInfoCard()
|
||||||
private let manageCard = UIView()
|
private let sectionTitleLabel = UILabel()
|
||||||
private let tabStack = UIStackView()
|
private let segmentedControl = UIView()
|
||||||
private let allTabButton = UIButton(type: .system)
|
private let allTabButton = UIButton(type: .system)
|
||||||
private let purchasedTabButton = UIButton(type: .system)
|
private let purchasedTabButton = UIButton(type: .system)
|
||||||
private let sortButton = UIButton(type: .system)
|
private let sortButton = UIButton(type: .system)
|
||||||
private let selectButton = UIButton(type: .system)
|
private let selectButton = UIButton(type: .system)
|
||||||
|
private let refreshControl = UIRefreshControl()
|
||||||
private var collectionView: UICollectionView!
|
private var collectionView: UICollectionView!
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
|
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
private let bottomActionStack = UIStackView()
|
private let bottomDivider = UIView()
|
||||||
|
private let selectionActionStack = UIStackView()
|
||||||
|
private let aiRetouchButton = UIButton(type: .system)
|
||||||
private let deleteSelectedButton = UIButton(type: .system)
|
private let deleteSelectedButton = UIButton(type: .system)
|
||||||
private let uploadButton = UIButton(type: .system)
|
private let uploadButton = UIButton(type: .system)
|
||||||
private var isDeleteSelectedButtonVisible = false
|
|
||||||
|
|
||||||
init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
|
init(
|
||||||
|
albumId: Int,
|
||||||
|
api: (any TravelAlbumServing)? = nil,
|
||||||
|
previewConfiguration: TravelAlbumPreviewConfiguration = .init(),
|
||||||
|
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }
|
||||||
|
) {
|
||||||
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
||||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
|
self.previewConfiguration = previewConfiguration
|
||||||
|
self.scenicIdProvider = scenicIdProvider
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +51,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "相册管理"
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = "相册管理"
|
||||||
|
titleLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||||
|
navigationItem.titleView = titleLabel
|
||||||
|
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithOpaqueBackground()
|
||||||
|
appearance.backgroundColor = .white
|
||||||
|
appearance.shadowColor = .clear
|
||||||
|
navigationItem.standardAppearance = appearance
|
||||||
|
navigationItem.scrollEdgeAppearance = appearance
|
||||||
|
navigationItem.compactAppearance = appearance
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
image: UIImage(systemName: "ellipsis"),
|
image: UIImage(systemName: "ellipsis"),
|
||||||
menu: UIMenu(children: [
|
menu: UIMenu(children: [
|
||||||
@@ -49,29 +73,38 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
navigationItem.rightBarButtonItem?.accessibilityLabel = "更多操作"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = AppColor.pageBackground
|
view.backgroundColor = TravelAlbumDetailStyle.pageBackground
|
||||||
|
|
||||||
manageCard.backgroundColor = .white
|
sectionTitleLabel.text = "照片"
|
||||||
manageCard.layer.cornerRadius = 12
|
sectionTitleLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
|
sectionTitleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||||
manageCard.layer.shadowOpacity = 1
|
|
||||||
manageCard.layer.shadowRadius = 6
|
|
||||||
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
||||||
|
|
||||||
tabStack.axis = .horizontal
|
segmentedControl.backgroundColor = .white
|
||||||
tabStack.spacing = 8
|
segmentedControl.layer.cornerRadius = 9
|
||||||
configurePillButton(allTabButton)
|
segmentedControl.layer.borderWidth = 1
|
||||||
configurePillButton(purchasedTabButton)
|
segmentedControl.layer.borderColor = TravelAlbumDetailStyle.border.cgColor
|
||||||
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill"))
|
segmentedControl.clipsToBounds = true
|
||||||
configureIconButton(selectButton, image: UIImage(systemName: "circle"))
|
configureTabButtonBase(allTabButton)
|
||||||
|
configureTabButtonBase(purchasedTabButton)
|
||||||
|
configureSortButton()
|
||||||
|
configureSelectButton()
|
||||||
|
|
||||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
collectionView.backgroundColor = .white
|
collectionView.backgroundColor = .clear
|
||||||
|
collectionView.alwaysBounceVertical = true
|
||||||
|
collectionView.showsVerticalScrollIndicator = false
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier)
|
refreshControl.tintColor = TravelAlbumDetailStyle.primary
|
||||||
|
refreshControl.accessibilityIdentifier = "travelAlbum.refreshControl"
|
||||||
|
collectionView.refreshControl = refreshControl
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumMaterialCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier
|
||||||
|
)
|
||||||
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
|
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
|
||||||
[weak self] collectionView, indexPath, material in
|
[weak self] collectionView, indexPath, material in
|
||||||
let cell = collectionView.dequeueReusableCell(
|
let cell = collectionView.dequeueReusableCell(
|
||||||
@@ -86,74 +119,105 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
bottomBar.backgroundColor = AppColor.pageBackground
|
bottomBar.backgroundColor = .white
|
||||||
bottomActionStack.axis = .vertical
|
bottomDivider.backgroundColor = TravelAlbumDetailStyle.border.withAlphaComponent(0.65)
|
||||||
bottomActionStack.spacing = 8
|
configureBottomButton(uploadButton, title: "上传照片", color: TravelAlbumDetailStyle.primary)
|
||||||
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
|
var uploadConfiguration = uploadButton.configuration
|
||||||
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
|
uploadConfiguration?.image = UIImage(systemName: "plus.circle.fill")
|
||||||
deleteSelectedButton.isHidden = true
|
uploadConfiguration?.imagePadding = 8
|
||||||
deleteSelectedButton.alpha = 0
|
uploadButton.configuration = uploadConfiguration
|
||||||
|
uploadButton.accessibilityLabel = "上传照片"
|
||||||
|
selectionActionStack.axis = .horizontal
|
||||||
|
selectionActionStack.alignment = .fill
|
||||||
|
selectionActionStack.distribution = .fillEqually
|
||||||
|
selectionActionStack.spacing = 12
|
||||||
|
selectionActionStack.isHidden = true
|
||||||
|
selectionActionStack.accessibilityIdentifier = "travelAlbum.selectionActionStack"
|
||||||
|
configureBottomButton(aiRetouchButton, title: "AI修图", color: TravelAlbumDetailStyle.primary)
|
||||||
|
aiRetouchButton.accessibilityLabel = "AI修图"
|
||||||
|
aiRetouchButton.accessibilityIdentifier = "travelAlbum.aiRetouchButton"
|
||||||
|
configureBottomButton(deleteSelectedButton, title: "删除", color: TravelAlbumDetailStyle.danger)
|
||||||
|
deleteSelectedButton.accessibilityLabel = "删除"
|
||||||
|
deleteSelectedButton.accessibilityIdentifier = "travelAlbum.deleteButton"
|
||||||
|
selectionActionStack.addArrangedSubview(aiRetouchButton)
|
||||||
|
selectionActionStack.addArrangedSubview(deleteSelectedButton)
|
||||||
|
|
||||||
view.addSubview(scrollContainer)
|
view.addSubview(contentView)
|
||||||
scrollContainer.addSubview(infoCard)
|
contentView.addSubview(infoCard)
|
||||||
scrollContainer.addSubview(manageCard)
|
contentView.addSubview(sectionTitleLabel)
|
||||||
manageCard.addSubview(tabStack)
|
contentView.addSubview(segmentedControl)
|
||||||
tabStack.addArrangedSubview(allTabButton)
|
segmentedControl.addSubview(allTabButton)
|
||||||
tabStack.addArrangedSubview(purchasedTabButton)
|
segmentedControl.addSubview(purchasedTabButton)
|
||||||
manageCard.addSubview(sortButton)
|
contentView.addSubview(sortButton)
|
||||||
manageCard.addSubview(selectButton)
|
contentView.addSubview(selectButton)
|
||||||
manageCard.addSubview(collectionView)
|
contentView.addSubview(collectionView)
|
||||||
view.addSubview(bottomBar)
|
view.addSubview(bottomBar)
|
||||||
bottomBar.addSubview(bottomActionStack)
|
bottomBar.addSubview(bottomDivider)
|
||||||
bottomActionStack.addArrangedSubview(deleteSelectedButton)
|
bottomBar.addSubview(uploadButton)
|
||||||
bottomActionStack.addArrangedSubview(uploadButton)
|
bottomBar.addSubview(selectionActionStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
bottomBar.snp.makeConstraints { make in
|
bottomBar.snp.makeConstraints { make in
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
bottomActionStack.snp.makeConstraints { make in
|
bottomDivider.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(12)
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.leading.trailing.equalToSuperview().inset(20)
|
make.height.equalTo(0.5)
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
|
||||||
}
|
|
||||||
deleteSelectedButton.snp.makeConstraints { make in
|
|
||||||
make.height.equalTo(48).priority(.high)
|
|
||||||
}
|
}
|
||||||
uploadButton.snp.makeConstraints { make in
|
uploadButton.snp.makeConstraints { make in
|
||||||
make.height.equalTo(48).priority(.high)
|
make.top.equalToSuperview().offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(18)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
}
|
}
|
||||||
scrollContainer.snp.makeConstraints { make in
|
selectionActionStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(uploadButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
make.leading.trailing.equalToSuperview().inset(16)
|
make.leading.trailing.equalToSuperview().inset(18)
|
||||||
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
}
|
}
|
||||||
infoCard.snp.makeConstraints { make in
|
infoCard.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalToSuperview()
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.height.greaterThanOrEqualTo(76)
|
make.height.equalTo(116)
|
||||||
}
|
}
|
||||||
manageCard.snp.makeConstraints { make in
|
sectionTitleLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(infoCard.snp.bottom).offset(12)
|
make.top.equalTo(infoCard.snp.bottom).offset(20)
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.equalToSuperview()
|
||||||
}
|
}
|
||||||
tabStack.snp.makeConstraints { make in
|
segmentedControl.snp.makeConstraints { make in
|
||||||
make.top.leading.equalToSuperview().offset(12)
|
make.top.equalTo(sectionTitleLabel.snp.bottom).offset(14)
|
||||||
|
make.leading.equalToSuperview()
|
||||||
|
make.width.equalTo(182)
|
||||||
|
make.height.equalTo(32)
|
||||||
|
}
|
||||||
|
allTabButton.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.leading.equalToSuperview()
|
||||||
|
make.width.equalToSuperview().multipliedBy(0.5)
|
||||||
|
}
|
||||||
|
purchasedTabButton.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.trailing.equalToSuperview()
|
||||||
|
make.leading.equalTo(allTabButton.snp.trailing)
|
||||||
|
}
|
||||||
|
selectButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(segmentedControl)
|
||||||
|
make.trailing.equalToSuperview()
|
||||||
|
make.width.equalTo(48)
|
||||||
make.height.equalTo(32)
|
make.height.equalTo(32)
|
||||||
}
|
}
|
||||||
sortButton.snp.makeConstraints { make in
|
sortButton.snp.makeConstraints { make in
|
||||||
make.centerY.equalTo(tabStack)
|
make.centerY.equalTo(segmentedControl)
|
||||||
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
|
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
|
||||||
make.size.equalTo(32)
|
make.width.equalTo(74)
|
||||||
}
|
make.height.equalTo(32)
|
||||||
selectButton.snp.makeConstraints { make in
|
make.leading.greaterThanOrEqualTo(segmentedControl.snp.trailing).offset(8)
|
||||||
make.centerY.equalTo(tabStack)
|
|
||||||
make.trailing.equalToSuperview().offset(-12)
|
|
||||||
make.size.equalTo(32)
|
|
||||||
}
|
}
|
||||||
collectionView.snp.makeConstraints { make in
|
collectionView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(tabStack.snp.bottom).offset(12)
|
make.top.equalTo(segmentedControl.snp.bottom).offset(14)
|
||||||
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,10 +225,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
infoCard.onCall = { [weak self] phone in self?.call(phone) }
|
infoCard.onCall = { [weak self] phone in self?.call(phone) }
|
||||||
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
|
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
|
||||||
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
|
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
|
||||||
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
|
|
||||||
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
|
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
|
||||||
|
aiRetouchButton.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
|
||||||
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
|
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
|
||||||
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
|
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
|
||||||
|
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in self?.applyViewModel() }
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
}
|
}
|
||||||
@@ -188,15 +253,40 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
if let album = viewModel.album {
|
if let album = viewModel.album {
|
||||||
infoCard.apply(album: album)
|
infoCard.apply(album: album, coverURL: viewModel.displayCoverURL)
|
||||||
}
|
}
|
||||||
configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
|
configureTabButton(allTabButton, title: "全部 \(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
|
||||||
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased)
|
configureTabButton(
|
||||||
selectButton.isHidden = viewModel.selectedTab != .all
|
purchasedTabButton,
|
||||||
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
|
title: "已购 \(viewModel.purchasedPhotoCount)",
|
||||||
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
|
selected: viewModel.selectedTab == .purchased
|
||||||
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
|
)
|
||||||
setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
|
|
||||||
|
var sortConfiguration = sortButton.configuration
|
||||||
|
sortConfiguration?.title = viewModel.sortOption.compactTitle
|
||||||
|
sortButton.configuration = sortConfiguration
|
||||||
|
sortButton.accessibilityValue = viewModel.sortOption.title
|
||||||
|
sortButton.menu = makeSortMenu()
|
||||||
|
|
||||||
|
let canSelectMaterials = viewModel.selectedTab == .all
|
||||||
|
selectButton.isHidden = false
|
||||||
|
selectButton.isEnabled = canSelectMaterials
|
||||||
|
selectButton.alpha = canSelectMaterials ? 1 : 0.45
|
||||||
|
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
||||||
|
selectButton.accessibilityValue = canSelectMaterials
|
||||||
|
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
|
||||||
|
: "已购照片不可删除"
|
||||||
|
|
||||||
|
let selectedCount = viewModel.selectedMaterialIds.count
|
||||||
|
let hasSelection = selectedCount > 0
|
||||||
|
aiRetouchButton.isEnabled = hasSelection
|
||||||
|
aiRetouchButton.alpha = hasSelection ? 1 : 0.45
|
||||||
|
aiRetouchButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
|
||||||
|
deleteSelectedButton.isEnabled = selectedCount > 0
|
||||||
|
deleteSelectedButton.alpha = hasSelection ? 1 : 0.45
|
||||||
|
deleteSelectedButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
|
||||||
|
selectionActionStack.isHidden = !viewModel.isSelectionMode
|
||||||
|
uploadButton.isHidden = viewModel.isSelectionMode
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
|
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
|
||||||
snapshot.appendSections([0])
|
snapshot.appendSections([0])
|
||||||
@@ -206,76 +296,109 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
||||||
if viewModel.isLoading && viewModel.album == nil {
|
if !viewModel.isRefreshing {
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
}
|
||||||
|
if viewModel.isLoading && viewModel.album == nil && !refreshControl.isRefreshing {
|
||||||
showLoading()
|
showLoading()
|
||||||
} else {
|
} else {
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configurePillButton(_ button: UIButton) {
|
@objc private func refreshPulled() {
|
||||||
|
Task { await viewModel.refreshAll(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTabButtonBase(_ button: UIButton) {
|
||||||
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
button.layer.cornerRadius = 16
|
button.accessibilityTraits.insert(.button)
|
||||||
button.setConfigurationContentInsets(
|
|
||||||
NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
|
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
|
||||||
button.setTitle(title, for: .normal)
|
button.setTitle(title, for: .normal)
|
||||||
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF)
|
button.backgroundColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||||||
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal)
|
button.setTitleColor(selected ? .white : TravelAlbumDetailStyle.textSecondary, for: .normal)
|
||||||
|
button.accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureIconButton(_ button: UIButton, image: UIImage?) {
|
private func configureSortButton() {
|
||||||
button.setImage(image, for: .normal)
|
var configuration = UIButton.Configuration.plain()
|
||||||
button.backgroundColor = UIColor(hex: 0xEAF4FF)
|
configuration.image = UIImage(named: "travel_album_sort_icon")
|
||||||
button.tintColor = AppColor.primary
|
configuration.imagePadding = 4
|
||||||
button.layer.cornerRadius = 16
|
configuration.title = viewModel.sortOption.compactTitle
|
||||||
|
configuration.baseForegroundColor = TravelAlbumDetailStyle.textPrimary
|
||||||
|
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 7, bottom: 0, trailing: 7)
|
||||||
|
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
sortButton.configuration = configuration
|
||||||
|
sortButton.backgroundColor = .white
|
||||||
|
sortButton.layer.cornerRadius = 9
|
||||||
|
sortButton.layer.borderWidth = 1
|
||||||
|
sortButton.layer.borderColor = TravelAlbumDetailStyle.border.cgColor
|
||||||
|
sortButton.showsMenuAsPrimaryAction = true
|
||||||
|
sortButton.accessibilityLabel = "排序"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureSelectButton() {
|
||||||
|
selectButton.setTitle("选择", for: .normal)
|
||||||
|
selectButton.setTitleColor(TravelAlbumDetailStyle.primary, for: .normal)
|
||||||
|
selectButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
selectButton.backgroundColor = .white
|
||||||
|
selectButton.layer.cornerRadius = 9
|
||||||
|
selectButton.layer.borderWidth = 1
|
||||||
|
selectButton.layer.borderColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.45).cgColor
|
||||||
|
selectButton.accessibilityLabel = "选择照片"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
|
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
|
||||||
button.setTitle(title, for: .normal)
|
var configuration = UIButton.Configuration.filled()
|
||||||
button.setTitleColor(.white, for: .normal)
|
configuration.title = title
|
||||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
configuration.baseBackgroundColor = color
|
||||||
button.backgroundColor = color
|
configuration.baseForegroundColor = .white
|
||||||
button.layer.cornerRadius = 10
|
configuration.cornerStyle = .fixed
|
||||||
|
configuration.background.cornerRadius = 14
|
||||||
|
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
button.configuration = configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setDeleteSelectedButtonVisible(_ visible: Bool) {
|
private func makeSortMenu() -> UIMenu {
|
||||||
guard visible != isDeleteSelectedButtonVisible else { return }
|
let actions = TravelAlbumDetailViewModel.SortOption.allCases.map { option in
|
||||||
isDeleteSelectedButtonVisible = visible
|
UIAction(
|
||||||
|
title: option.title,
|
||||||
let changes = {
|
state: option == viewModel.sortOption ? .on : .off
|
||||||
self.deleteSelectedButton.isHidden = !visible
|
) { [weak self] _ in
|
||||||
self.deleteSelectedButton.alpha = visible ? 1 : 0
|
guard let self else { return }
|
||||||
self.view.layoutIfNeeded()
|
Task { await self.viewModel.setSortOption(option, api: self.api) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard view.window != nil else {
|
return UIMenu(title: "排序方式", options: .singleSelection, children: actions)
|
||||||
changes()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
view.layoutIfNeeded()
|
|
||||||
UIView.animate(
|
|
||||||
withDuration: 0.25,
|
|
||||||
delay: 0,
|
|
||||||
options: [.curveEaseInOut, .beginFromCurrentState],
|
|
||||||
animations: changes
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||||
UICollectionViewCompositionalLayout { _, environment in
|
UICollectionViewCompositionalLayout { _, environment in
|
||||||
let spacing: CGFloat = 8
|
let spacing: CGFloat = 10
|
||||||
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3
|
let width = floor((environment.container.effectiveContentSize.width - spacing * 2) / 3)
|
||||||
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38))
|
let itemSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .absolute(width),
|
||||||
|
heightDimension: .absolute(width + 40)
|
||||||
|
)
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 38))
|
let groupSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .absolute(width + 40)
|
||||||
|
)
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
|
||||||
group.interItemSpacing = .fixed(spacing)
|
group.interItemSpacing = .fixed(spacing)
|
||||||
let section = NSCollectionLayoutSection(group: group)
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
section.interGroupSpacing = 12
|
section.interGroupSpacing = 14
|
||||||
return section
|
return section
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,24 +426,38 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
Task { await viewModel.selectTab(.purchased, api: api) }
|
Task { await viewModel.selectTab(.purchased, api: api) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func sortTapped() {
|
|
||||||
let alert = UIAlertController(title: "排序", message: nil, preferredStyle: .actionSheet)
|
|
||||||
TravelAlbumDetailViewModel.SortOption.allCases.forEach { option in
|
|
||||||
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { await self.viewModel.setSortOption(option, api: self.api) }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func selectTapped() {
|
@objc private func selectTapped() {
|
||||||
viewModel.toggleSelectionMode()
|
viewModel.toggleSelectionMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func aiRetouchTapped() {
|
||||||
|
let materialIds = Array(viewModel.selectedMaterialIds)
|
||||||
|
guard !materialIds.isEmpty else { return }
|
||||||
|
let scenicId = scenicIdProvider()
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
showToast("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||||
|
viewModel: TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: viewModel.albumId,
|
||||||
|
scenicId: scenicId,
|
||||||
|
materialIds: materialIds
|
||||||
|
),
|
||||||
|
api: api,
|
||||||
|
onSubmitted: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.viewModel.completeAIRetouchSubmission()
|
||||||
|
self.showToast("AI修图任务已提交")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
present(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func deleteSelectedTapped() {
|
@objc private func deleteSelectedTapped() {
|
||||||
let count = viewModel.selectedMaterialIds.count
|
let count = viewModel.selectedMaterialIds.count
|
||||||
|
guard count > 0 else { return }
|
||||||
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||||
@@ -341,12 +478,50 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
)
|
)
|
||||||
navigationController?.pushViewController(controller, animated: true)
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentPreview(startingWith material: TravelAlbumMaterial) {
|
||||||
|
guard let startIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) else { return }
|
||||||
|
let previewViewModel = viewModel
|
||||||
|
let previewAPI = api
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: viewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
|
||||||
|
totalCount: viewModel.currentPhotoCount,
|
||||||
|
startProjectIndex: startIndex,
|
||||||
|
configuration: previewConfiguration,
|
||||||
|
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
|
||||||
|
albumId: viewModel.albumId,
|
||||||
|
scenicIdProvider: scenicIdProvider,
|
||||||
|
aiRetouchAPI: previewAPI,
|
||||||
|
loadMore: {
|
||||||
|
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
|
||||||
|
return (
|
||||||
|
previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
|
||||||
|
previewViewModel.currentPhotoCount
|
||||||
|
)
|
||||||
|
},
|
||||||
|
reload: {
|
||||||
|
let response = try await previewViewModel.reloadLoadedMaterials(api: previewAPI)
|
||||||
|
return (
|
||||||
|
response.list.map(TravelAlbumPreviewProject.init(material:)),
|
||||||
|
response.total
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onProjectDeleted: { materialId in
|
||||||
|
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
present(controller, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
|
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
|
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
viewModel.toggleMaterialSelection(material)
|
if viewModel.isSelectionMode {
|
||||||
|
viewModel.toggleMaterialSelection(material)
|
||||||
|
} else {
|
||||||
|
presentPreview(startingWith: material)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
@@ -355,66 +530,111 @@ extension TravelAlbumDetailViewController: UICollectionViewDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册详情信息卡。
|
/// 相册管理页专用视觉常量,避免污染全局主题。
|
||||||
|
private enum TravelAlbumDetailStyle {
|
||||||
|
static let primary = UIColor(hex: 0x1677FF)
|
||||||
|
static let pageBackground = UIColor(hex: 0xF8FAFD)
|
||||||
|
static let textPrimary = UIColor(hex: 0x172033)
|
||||||
|
static let textSecondary = UIColor(hex: 0x7F8A9E)
|
||||||
|
static let border = UIColor(hex: 0xDCE4EF)
|
||||||
|
static let danger = UIColor(hex: 0xE53935)
|
||||||
|
|
||||||
|
static func badgeColor(for kind: TravelAlbumMaterialBadgeKind) -> UIColor {
|
||||||
|
switch kind {
|
||||||
|
case .purchased: UIColor(hex: 0x475569)
|
||||||
|
case .pending: UIColor(hex: 0xB45309)
|
||||||
|
case .processing: UIColor(hex: 0x1D4ED8)
|
||||||
|
case .retouched: UIColor(hex: 0x047857)
|
||||||
|
case .cover: UIColor(hex: 0x6D28D9)
|
||||||
|
case .failed: UIColor(hex: 0xB91C1C)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册摘要卡,展示封面、名称、用户手机号与创建时间。
|
||||||
private final class TravelAlbumInfoCard: UIView {
|
private final class TravelAlbumInfoCard: UIView {
|
||||||
var onCall: ((String) -> Void)?
|
var onCall: ((String) -> Void)?
|
||||||
private var phone = ""
|
private var phone = ""
|
||||||
|
|
||||||
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
|
private let backgroundImageView = UIImageView(image: UIImage(named: "travel_album_header_background"))
|
||||||
|
private let coverImageView = UIImageView()
|
||||||
|
private let coverPhotoIconView = UIImageView(image: UIImage(named: "travel_album_cover_photo_icon"))
|
||||||
|
private let nameLabel = UILabel()
|
||||||
private let phoneLabel = UILabel()
|
private let phoneLabel = UILabel()
|
||||||
private let timeLabel = UILabel()
|
private let timeLabel = UILabel()
|
||||||
private let callButton = UIButton(type: .system)
|
private let callButton = UIButton(type: .system)
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = .white
|
clipsToBounds = true
|
||||||
layer.cornerRadius = 12
|
layer.cornerRadius = 16
|
||||||
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
|
|
||||||
layer.shadowOpacity = 1
|
backgroundImageView.contentMode = .scaleAspectFill
|
||||||
layer.shadowRadius = 6
|
coverImageView.contentMode = .scaleAspectFill
|
||||||
layer.shadowOffset = CGSize(width: 0, height: 2)
|
coverImageView.clipsToBounds = true
|
||||||
|
coverImageView.layer.cornerRadius = 12
|
||||||
|
coverImageView.backgroundColor = UIColor(hex: 0xDCEEFF)
|
||||||
|
coverPhotoIconView.contentMode = .scaleAspectFit
|
||||||
|
coverPhotoIconView.layer.shadowColor = UIColor.black.withAlphaComponent(0.3).cgColor
|
||||||
|
coverPhotoIconView.layer.shadowOpacity = 1
|
||||||
|
coverPhotoIconView.layer.shadowRadius = 2
|
||||||
|
coverPhotoIconView.layer.shadowOffset = .zero
|
||||||
|
|
||||||
|
nameLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
|
nameLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
phoneLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
phoneLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
|
timeLabel.font = .systemFont(ofSize: 11)
|
||||||
|
timeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||||||
|
timeLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
timeLabel.minimumScaleFactor = 0.85
|
||||||
|
|
||||||
let iconBox = UIView()
|
|
||||||
iconBox.backgroundColor = AppColor.primary
|
|
||||||
iconBox.layer.cornerRadius = 10
|
|
||||||
iconView.tintColor = .white
|
|
||||||
iconView.contentMode = .scaleAspectFit
|
|
||||||
phoneLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
|
||||||
phoneLabel.textColor = AppColor.textPrimary
|
|
||||||
timeLabel.font = .systemFont(ofSize: 12)
|
|
||||||
timeLabel.textColor = AppColor.textTertiary
|
|
||||||
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||||
callButton.tintColor = AppColor.primary
|
callButton.tintColor = TravelAlbumDetailStyle.primary
|
||||||
callButton.backgroundColor = UIColor(hex: 0xEAF4FF)
|
callButton.backgroundColor = .white.withAlphaComponent(0.9)
|
||||||
callButton.layer.cornerRadius = 20
|
callButton.layer.cornerRadius = 22
|
||||||
|
callButton.layer.shadowColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.18).cgColor
|
||||||
|
callButton.layer.shadowOpacity = 1
|
||||||
|
callButton.layer.shadowRadius = 6
|
||||||
|
callButton.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
|
callButton.accessibilityLabel = "拨打相册用户电话"
|
||||||
|
|
||||||
addSubview(iconBox)
|
addSubview(backgroundImageView)
|
||||||
iconBox.addSubview(iconView)
|
addSubview(coverImageView)
|
||||||
|
coverImageView.addSubview(coverPhotoIconView)
|
||||||
|
addSubview(nameLabel)
|
||||||
addSubview(phoneLabel)
|
addSubview(phoneLabel)
|
||||||
addSubview(timeLabel)
|
addSubview(timeLabel)
|
||||||
addSubview(callButton)
|
addSubview(callButton)
|
||||||
iconBox.snp.makeConstraints { make in
|
|
||||||
make.leading.top.bottom.equalToSuperview().inset(14)
|
backgroundImageView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
make.size.equalTo(48)
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.top.bottom.equalToSuperview().inset(20)
|
||||||
|
make.width.equalTo(68)
|
||||||
}
|
}
|
||||||
iconView.snp.makeConstraints { make in
|
coverPhotoIconView.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.trailing.bottom.equalToSuperview().inset(5)
|
||||||
make.size.equalTo(28)
|
make.size.equalTo(22)
|
||||||
|
}
|
||||||
|
callButton.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(44)
|
||||||
|
}
|
||||||
|
nameLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(21)
|
||||||
|
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalTo(callButton.snp.leading).offset(-10)
|
||||||
}
|
}
|
||||||
phoneLabel.snp.makeConstraints { make in
|
phoneLabel.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(16)
|
make.top.equalTo(nameLabel.snp.bottom).offset(8)
|
||||||
make.leading.equalTo(iconBox.snp.trailing).offset(12)
|
make.leading.trailing.equalTo(nameLabel)
|
||||||
make.trailing.equalTo(callButton.snp.leading).offset(-12)
|
|
||||||
}
|
}
|
||||||
timeLabel.snp.makeConstraints { make in
|
timeLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
|
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
|
||||||
make.leading.trailing.equalTo(phoneLabel)
|
make.leading.trailing.equalTo(nameLabel)
|
||||||
make.bottom.equalToSuperview().offset(-16)
|
|
||||||
}
|
|
||||||
callButton.snp.makeConstraints { make in
|
|
||||||
make.trailing.equalToSuperview().offset(-14)
|
|
||||||
make.centerY.equalToSuperview()
|
|
||||||
make.size.equalTo(40)
|
|
||||||
}
|
}
|
||||||
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
||||||
}
|
}
|
||||||
@@ -424,10 +644,21 @@ private final class TravelAlbumInfoCard: UIView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(album: TravelAlbum) {
|
func apply(album: TravelAlbum, coverURL: String) {
|
||||||
phone = album.displayPhone
|
phone = album.displayPhone
|
||||||
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))"
|
nameLabel.text = album.name.isEmpty ? "旅拍相册" : album.name
|
||||||
timeLabel.text = "创建时间 \(album.createdAt)"
|
phoneLabel.text = TravelAlbumDisplayFormatter.maskPhone(album.displayPhone)
|
||||||
|
timeLabel.text = "创建于 \(TravelAlbumDisplayFormatter.creationTimeText(album.createdAt))"
|
||||||
|
if let url = URL(string: coverURL), !coverURL.isEmpty {
|
||||||
|
coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill"))
|
||||||
|
coverImageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.45)
|
||||||
|
} else {
|
||||||
|
coverImageView.image = UIImage(systemName: "photo.fill")
|
||||||
|
coverImageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35)
|
||||||
|
coverImageView.contentMode = .center
|
||||||
|
}
|
||||||
|
callButton.isEnabled = !phone.isEmpty
|
||||||
|
callButton.alpha = phone.isEmpty ? 0.45 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func callTapped() {
|
@objc private func callTapped() {
|
||||||
@@ -435,13 +666,14 @@ private final class TravelAlbumInfoCard: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册素材网格 cell。
|
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
|
||||||
private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||||
|
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
private let statusLabel = UILabel()
|
|
||||||
private let checkImageView = UIImageView()
|
private let checkImageView = UIImageView()
|
||||||
|
private let badgeView = UIView()
|
||||||
|
private let badgeLabel = UILabel()
|
||||||
private let nameLabel = UILabel()
|
private let nameLabel = UILabel()
|
||||||
private let sizeLabel = UILabel()
|
private let sizeLabel = UILabel()
|
||||||
|
|
||||||
@@ -449,25 +681,33 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
imageView.clipsToBounds = true
|
imageView.clipsToBounds = true
|
||||||
imageView.layer.cornerRadius = 8
|
imageView.layer.cornerRadius = 12
|
||||||
statusLabel.text = "已上传"
|
imageView.backgroundColor = UIColor(hex: 0xEAF4FF)
|
||||||
statusLabel.font = .systemFont(ofSize: 10)
|
|
||||||
statusLabel.textColor = .white
|
|
||||||
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
|
|
||||||
statusLabel.layer.cornerRadius = 4
|
|
||||||
statusLabel.clipsToBounds = true
|
|
||||||
statusLabel.textAlignment = .center
|
|
||||||
checkImageView.tintColor = .white
|
checkImageView.tintColor = .white
|
||||||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
checkImageView.layer.cornerRadius = 10
|
checkImageView.layer.cornerRadius = 11
|
||||||
nameLabel.font = .systemFont(ofSize: 11)
|
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
||||||
nameLabel.textColor = AppColor.textPrimary
|
badgeView.layer.cornerRadius = 5
|
||||||
|
badgeView.clipsToBounds = true
|
||||||
|
badgeView.isHidden = true
|
||||||
|
badgeView.isAccessibilityElement = false
|
||||||
|
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
|
||||||
|
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
|
||||||
|
badgeLabel.textColor = .white
|
||||||
|
badgeLabel.textAlignment = .center
|
||||||
|
badgeLabel.isAccessibilityElement = false
|
||||||
|
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
|
||||||
|
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
sizeLabel.font = .systemFont(ofSize: 10)
|
sizeLabel.font = .systemFont(ofSize: 10)
|
||||||
sizeLabel.textColor = AppColor.textTertiary
|
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||||||
|
|
||||||
contentView.addSubview(imageView)
|
contentView.addSubview(imageView)
|
||||||
imageView.addSubview(statusLabel)
|
imageView.addSubview(badgeView)
|
||||||
|
badgeView.addSubview(badgeLabel)
|
||||||
imageView.addSubview(checkImageView)
|
imageView.addSubview(checkImageView)
|
||||||
contentView.addSubview(nameLabel)
|
contentView.addSubview(nameLabel)
|
||||||
contentView.addSubview(sizeLabel)
|
contentView.addSubview(sizeLabel)
|
||||||
@@ -475,17 +715,21 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
make.top.leading.trailing.equalToSuperview()
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.height.equalTo(imageView.snp.width)
|
make.height.equalTo(imageView.snp.width)
|
||||||
}
|
}
|
||||||
statusLabel.snp.makeConstraints { make in
|
|
||||||
make.top.leading.equalToSuperview().offset(4)
|
|
||||||
make.height.equalTo(18)
|
|
||||||
make.width.equalTo(48)
|
|
||||||
}
|
|
||||||
checkImageView.snp.makeConstraints { make in
|
checkImageView.snp.makeConstraints { make in
|
||||||
make.top.trailing.equalToSuperview().inset(4)
|
make.top.trailing.equalToSuperview().inset(6)
|
||||||
make.size.equalTo(20)
|
make.size.equalTo(22)
|
||||||
|
}
|
||||||
|
badgeView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().inset(6)
|
||||||
|
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
||||||
|
}
|
||||||
|
badgeLabel.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(
|
||||||
|
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
nameLabel.snp.makeConstraints { make in
|
nameLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(imageView.snp.bottom).offset(4)
|
make.top.equalTo(imageView.snp.bottom).offset(6)
|
||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
}
|
}
|
||||||
sizeLabel.snp.makeConstraints { make in
|
sizeLabel.snp.makeConstraints { make in
|
||||||
@@ -501,17 +745,25 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
|
|
||||||
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
|
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
|
||||||
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
|
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
if let url = URL(string: urlString), !urlString.isEmpty {
|
if let url = URL(string: urlString), !urlString.isEmpty {
|
||||||
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
|
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill"))
|
||||||
} else {
|
} else {
|
||||||
imageView.image = UIImage(systemName: "photo")
|
imageView.image = UIImage(systemName: "photo.fill")
|
||||||
imageView.tintColor = AppColor.primary
|
imageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35)
|
||||||
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
|
imageView.contentMode = .center
|
||||||
}
|
}
|
||||||
checkImageView.isHidden = !selectionMode
|
checkImageView.isHidden = !selectionMode
|
||||||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
checkImageView.tintColor = selected ? AppColor.primary : .white
|
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||||||
nameLabel.text = material.fileName
|
let badge = material.badgePresentation
|
||||||
|
badgeView.isHidden = badge == nil
|
||||||
|
badgeLabel.text = badge?.text
|
||||||
|
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
||||||
|
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
||||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||||
|
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
|
||||||
|
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
|
||||||
|
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,897 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumPhotoPreviewViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
|
||||||
|
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
|
||||||
|
|
||||||
|
/// 相册项目刷新回调,失败时由预览页保留当前内容并展示错误。
|
||||||
|
typealias TravelAlbumPreviewReload = () async throws -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
|
||||||
|
|
||||||
|
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
|
||||||
|
final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||||
|
private var projects: [TravelAlbumPreviewProject]
|
||||||
|
private var totalCount: Int
|
||||||
|
private let configuration: TravelAlbumPreviewConfiguration
|
||||||
|
private let actionHandler: any TravelAlbumPreviewActionHandling
|
||||||
|
private let albumId: Int
|
||||||
|
private let scenicIdProvider: () -> Int
|
||||||
|
private let aiRetouchAPI: (any TravelAlbumServing)?
|
||||||
|
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||||
|
private let reload: TravelAlbumPreviewReload?
|
||||||
|
private let onProjectDeleted: ((Int) -> Void)?
|
||||||
|
private var nodes: [TravelAlbumPreviewNode] = []
|
||||||
|
private var currentNodeIndex = 0
|
||||||
|
private var dragStartIndex = 0
|
||||||
|
private var selectedKind: TravelAlbumPreviewAssetKind = .original
|
||||||
|
private var chromeVisible = true
|
||||||
|
private var isLoadingMore = false
|
||||||
|
private var isRefreshingProject = false
|
||||||
|
private var isDeletingProject = false
|
||||||
|
private var didApplyInitialPosition = false
|
||||||
|
private var lastCollectionSize: CGSize = .zero
|
||||||
|
|
||||||
|
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
|
private let topChrome = UIView()
|
||||||
|
private let closeButton = UIButton(type: .system)
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let sizeLabel = UILabel()
|
||||||
|
private let counterLabel = UILabel()
|
||||||
|
private let bottomChrome = UIView()
|
||||||
|
private let divider = UIView()
|
||||||
|
private let tabStack = UIStackView()
|
||||||
|
private let actionStack = UIStackView()
|
||||||
|
private let deleteButton = UIButton(type: .system)
|
||||||
|
private let refreshButton = UIButton(type: .system)
|
||||||
|
private var tabHeightConstraint: Constraint?
|
||||||
|
|
||||||
|
/// 创建全屏预览页。
|
||||||
|
init(
|
||||||
|
projects: [TravelAlbumPreviewProject],
|
||||||
|
totalCount: Int,
|
||||||
|
startProjectIndex: Int,
|
||||||
|
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||||||
|
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||||||
|
albumId: Int = 0,
|
||||||
|
scenicIdProvider: @escaping () -> Int = { 0 },
|
||||||
|
aiRetouchAPI: (any TravelAlbumServing)? = nil,
|
||||||
|
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
||||||
|
reload: TravelAlbumPreviewReload? = nil,
|
||||||
|
onProjectDeleted: ((Int) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.projects = Self.deduplicated(projects)
|
||||||
|
self.totalCount = max(totalCount, projects.count)
|
||||||
|
self.configuration = configuration
|
||||||
|
self.actionHandler = actionHandler
|
||||||
|
self.albumId = albumId
|
||||||
|
self.scenicIdProvider = scenicIdProvider
|
||||||
|
self.aiRetouchAPI = aiRetouchAPI
|
||||||
|
self.loadMore = loadMore
|
||||||
|
self.reload = reload
|
||||||
|
self.onProjectDeleted = onProjectDeleted
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .fullScreen
|
||||||
|
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override var prefersStatusBarHidden: Bool { true }
|
||||||
|
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { [.bottom] }
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
setupUI()
|
||||||
|
updateForCurrentNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
let collectionSize = collectionView.bounds.size
|
||||||
|
if collectionSize != lastCollectionSize {
|
||||||
|
lastCollectionSize = collectionSize
|
||||||
|
collectionView.collectionViewLayout.invalidateLayout()
|
||||||
|
if didApplyInitialPosition {
|
||||||
|
setPage(currentNodeIndex, animated: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !didApplyInitialPosition, collectionView.bounds.width > 0 else { return }
|
||||||
|
didApplyInitialPosition = true
|
||||||
|
setPage(currentNodeIndex, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
view.backgroundColor = .black
|
||||||
|
collectionView.backgroundColor = .black
|
||||||
|
collectionView.isPagingEnabled = true
|
||||||
|
collectionView.alwaysBounceHorizontal = nodes.count > 1
|
||||||
|
collectionView.showsHorizontalScrollIndicator = false
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.dataSource = self
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumPreviewImageCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
|
topChrome.backgroundColor = UIColor.black.withAlphaComponent(0.72)
|
||||||
|
bottomChrome.backgroundColor = UIColor.black.withAlphaComponent(0.78)
|
||||||
|
divider.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
||||||
|
|
||||||
|
closeButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)), for: .normal)
|
||||||
|
closeButton.tintColor = .white
|
||||||
|
closeButton.accessibilityLabel = "关闭图片预览"
|
||||||
|
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
titleLabel.textColor = .white
|
||||||
|
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
|
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.65)
|
||||||
|
sizeLabel.font = .systemFont(ofSize: 11, weight: .regular)
|
||||||
|
sizeLabel.textAlignment = .center
|
||||||
|
|
||||||
|
counterLabel.textColor = .white
|
||||||
|
counterLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
|
||||||
|
counterLabel.textAlignment = .right
|
||||||
|
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
|
||||||
|
tabStack.axis = .horizontal
|
||||||
|
tabStack.distribution = .fillEqually
|
||||||
|
tabStack.alignment = .fill
|
||||||
|
actionStack.axis = .horizontal
|
||||||
|
actionStack.distribution = .fillEqually
|
||||||
|
actionStack.alignment = .fill
|
||||||
|
configureActions()
|
||||||
|
|
||||||
|
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
|
||||||
|
titleStack.axis = .vertical
|
||||||
|
titleStack.spacing = 3
|
||||||
|
titleStack.alignment = .fill
|
||||||
|
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
view.addSubview(topChrome)
|
||||||
|
topChrome.addSubview(closeButton)
|
||||||
|
topChrome.addSubview(titleStack)
|
||||||
|
topChrome.addSubview(counterLabel)
|
||||||
|
view.addSubview(bottomChrome)
|
||||||
|
bottomChrome.addSubview(divider)
|
||||||
|
bottomChrome.addSubview(tabStack)
|
||||||
|
bottomChrome.addSubview(actionStack)
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
topChrome.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(64)
|
||||||
|
}
|
||||||
|
closeButton.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(8)
|
||||||
|
make.bottom.equalToSuperview().offset(-10)
|
||||||
|
make.size.equalTo(44)
|
||||||
|
}
|
||||||
|
counterLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
make.centerY.equalTo(closeButton)
|
||||||
|
make.width.greaterThanOrEqualTo(52)
|
||||||
|
}
|
||||||
|
titleStack.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(closeButton)
|
||||||
|
make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8)
|
||||||
|
make.trailing.lessThanOrEqualTo(counterLabel.snp.leading).offset(-8)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.width.lessThanOrEqualTo(220)
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomChrome.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
divider.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(0.5)
|
||||||
|
}
|
||||||
|
tabStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(divider.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
tabHeightConstraint = make.height.equalTo(0).constraint
|
||||||
|
}
|
||||||
|
actionStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(tabStack.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(8)
|
||||||
|
make.height.equalTo(70)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureActions() {
|
||||||
|
let aiButton = makeActionButton(title: "AI修图", systemName: "wand.and.stars", color: UIColor(hex: 0x60A5FA))
|
||||||
|
let deleteConfiguration = makeActionButton(
|
||||||
|
title: "删除",
|
||||||
|
systemName: "trash",
|
||||||
|
color: UIColor(hex: 0xF87171)
|
||||||
|
).configuration
|
||||||
|
deleteButton.configuration = deleteConfiguration
|
||||||
|
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
deleteButton.accessibilityLabel = "删除"
|
||||||
|
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
|
||||||
|
refreshButton.configuration = makeActionButton(
|
||||||
|
title: "刷新",
|
||||||
|
systemName: "arrow.clockwise",
|
||||||
|
color: .white
|
||||||
|
).configuration
|
||||||
|
refreshButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
refreshButton.accessibilityLabel = "刷新"
|
||||||
|
refreshButton.accessibilityIdentifier = "travelAlbum.previewRefreshButton"
|
||||||
|
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
|
||||||
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
|
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||||
|
[aiButton, deleteButton, refreshButton].forEach(actionStack.addArrangedSubview)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeActionButton(title: String, systemName: String, color: UIColor) -> UIButton {
|
||||||
|
var configuration = UIButton.Configuration.plain()
|
||||||
|
configuration.title = title
|
||||||
|
configuration.image = UIImage(systemName: systemName)
|
||||||
|
configuration.imagePlacement = .top
|
||||||
|
configuration.imagePadding = 5
|
||||||
|
configuration.baseForegroundColor = color
|
||||||
|
configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)
|
||||||
|
let button = UIButton(configuration: configuration)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
button.accessibilityLabel = title
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLayout() -> UICollectionViewLayout {
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.scrollDirection = .horizontal
|
||||||
|
layout.minimumLineSpacing = 0
|
||||||
|
layout.minimumInteritemSpacing = 0
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentNode: TravelAlbumPreviewNode? {
|
||||||
|
nodes.indices.contains(currentNodeIndex) ? nodes[currentNodeIndex] : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentProject: TravelAlbumPreviewProject? {
|
||||||
|
guard let node = currentNode, projects.indices.contains(node.projectIndex) else { return nil }
|
||||||
|
return projects[node.projectIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentAsset: TravelAlbumPreviewAsset? {
|
||||||
|
guard let node = currentNode, let project = currentProject else { return nil }
|
||||||
|
let kind = configuration.swipeMode == .projectsOnly ? selectedKind : node.kind
|
||||||
|
return project.asset(for: kind) ?? project.asset(for: .original)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateForCurrentNode() {
|
||||||
|
guard let node = currentNode else { return }
|
||||||
|
let asset = currentAsset
|
||||||
|
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
||||||
|
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0)
|
||||||
|
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
||||||
|
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
|
||||||
|
rebuildTabs()
|
||||||
|
loadMoreIfNeeded(projectIndex: node.projectIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildTabs() {
|
||||||
|
tabStack.arrangedSubviews.forEach {
|
||||||
|
tabStack.removeArrangedSubview($0)
|
||||||
|
$0.removeFromSuperview()
|
||||||
|
}
|
||||||
|
guard let project = currentProject, project.hasVariants else {
|
||||||
|
tabStack.isHidden = true
|
||||||
|
tabHeightConstraint?.update(offset: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tabStack.isHidden = false
|
||||||
|
tabHeightConstraint?.update(offset: 44)
|
||||||
|
let activeKind = currentAsset?.kind ?? .original
|
||||||
|
for asset in project.orderedAssets {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.tag = asset.kind.rawValue
|
||||||
|
button.setTitle(asset.kind.title, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 13, weight: asset.kind == activeKind ? .semibold : .medium)
|
||||||
|
button.setTitleColor(asset.kind == activeKind ? .white : UIColor.white.withAlphaComponent(0.55), for: .normal)
|
||||||
|
button.backgroundColor = asset.kind == activeKind ? UIColor.white.withAlphaComponent(0.12) : .clear
|
||||||
|
button.layer.cornerRadius = 8
|
||||||
|
button.accessibilityLabel = asset.kind.title
|
||||||
|
button.accessibilityTraits = asset.kind == activeKind ? [.button, .selected] : .button
|
||||||
|
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
||||||
|
tabStack.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
if !chromeVisible {
|
||||||
|
bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func tabTapped(_ sender: UIButton) {
|
||||||
|
guard let kind = TravelAlbumPreviewAssetKind(rawValue: sender.tag),
|
||||||
|
let node = currentNode,
|
||||||
|
currentProject?.asset(for: kind) != nil
|
||||||
|
else { return }
|
||||||
|
switch configuration.swipeMode {
|
||||||
|
case .projectsOnly:
|
||||||
|
selectedKind = kind
|
||||||
|
collectionView.reloadItems(at: [IndexPath(item: currentNodeIndex, section: 0)])
|
||||||
|
updateForCurrentNode()
|
||||||
|
case .includeVariants:
|
||||||
|
guard let target = nodes.firstIndex(where: { $0.projectIndex == node.projectIndex && $0.kind == kind }) else { return }
|
||||||
|
currentNodeIndex = target
|
||||||
|
setPage(target, animated: true)
|
||||||
|
updateForCurrentNode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func didChangePage(to index: Int) {
|
||||||
|
guard nodes.indices.contains(index) else { return }
|
||||||
|
let oldNodeIndex = currentNodeIndex
|
||||||
|
let oldProjectIndex = currentNode?.projectIndex
|
||||||
|
guard oldNodeIndex != index else {
|
||||||
|
updateForCurrentNode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentNodeIndex = index
|
||||||
|
if oldProjectIndex != currentNode?.projectIndex {
|
||||||
|
selectedKind = .original
|
||||||
|
resetProjectCellToOriginalIfNeeded(at: oldNodeIndex)
|
||||||
|
}
|
||||||
|
collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() }
|
||||||
|
updateForCurrentNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetProjectCellToOriginalIfNeeded(at nodeIndex: Int) {
|
||||||
|
guard configuration.swipeMode == .projectsOnly,
|
||||||
|
nodes.indices.contains(nodeIndex),
|
||||||
|
let cell = collectionView.cellForItem(at: IndexPath(item: nodeIndex, section: 0)) as? TravelAlbumPreviewImageCell
|
||||||
|
else { return }
|
||||||
|
let project = projects[nodes[nodeIndex].projectIndex]
|
||||||
|
cell.apply(asset: project.asset(for: .original))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setPage(_ index: Int, animated: Bool) {
|
||||||
|
guard collectionView.bounds.width > 0, nodes.indices.contains(index) else { return }
|
||||||
|
collectionView.setContentOffset(CGPoint(x: CGFloat(index) * collectionView.bounds.width, y: 0), animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildNodes(keepingProjectIndex: Int, kind: TravelAlbumPreviewAssetKind) {
|
||||||
|
nodes = TravelAlbumPreviewNavigator.nodes(projects: projects, mode: configuration.swipeMode)
|
||||||
|
currentNodeIndex = nodes.firstIndex {
|
||||||
|
$0.projectIndex == keepingProjectIndex &&
|
||||||
|
(configuration.swipeMode == .projectsOnly || $0.kind == kind)
|
||||||
|
} ?? nodes.firstIndex { $0.projectIndex == keepingProjectIndex } ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMoreIfNeeded(projectIndex: Int) {
|
||||||
|
guard projectIndex >= projects.count - 4,
|
||||||
|
projects.count < totalCount,
|
||||||
|
!isLoadingMore,
|
||||||
|
let loadMore
|
||||||
|
else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
let currentProjectId = currentProject?.id
|
||||||
|
let kind = currentAsset?.kind ?? .original
|
||||||
|
Task { [weak self] in
|
||||||
|
let result = await loadMore()
|
||||||
|
guard let self else { return }
|
||||||
|
let incoming = Self.deduplicated(result.projects)
|
||||||
|
guard incoming.count > self.projects.count else {
|
||||||
|
self.totalCount = max(result.totalCount, incoming.count)
|
||||||
|
self.isLoadingMore = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.projects = incoming
|
||||||
|
self.totalCount = max(result.totalCount, incoming.count)
|
||||||
|
let projectIndex = currentProjectId.flatMap { id in incoming.firstIndex { $0.id == id } } ?? 0
|
||||||
|
self.rebuildNodes(keepingProjectIndex: projectIndex, kind: kind)
|
||||||
|
self.collectionView.reloadData()
|
||||||
|
self.collectionView.layoutIfNeeded()
|
||||||
|
self.setPage(self.currentNodeIndex, animated: false)
|
||||||
|
self.isLoadingMore = false
|
||||||
|
self.updateForCurrentNode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deduplicated(_ projects: [TravelAlbumPreviewProject]) -> [TravelAlbumPreviewProject] {
|
||||||
|
var seen = Set<Int>()
|
||||||
|
return projects.filter { seen.insert($0.id).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleChrome() {
|
||||||
|
chromeVisible.toggle()
|
||||||
|
let reduceMotion = UIAccessibility.isReduceMotionEnabled
|
||||||
|
let animations = {
|
||||||
|
self.topChrome.alpha = self.chromeVisible ? 1 : 0
|
||||||
|
self.bottomChrome.alpha = self.chromeVisible ? 1 : 0
|
||||||
|
self.topChrome.transform = self.chromeVisible
|
||||||
|
? .identity
|
||||||
|
: CGAffineTransform(translationX: 0, y: -self.topChrome.bounds.height)
|
||||||
|
self.bottomChrome.transform = self.chromeVisible
|
||||||
|
? .identity
|
||||||
|
: CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height)
|
||||||
|
}
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: reduceMotion ? 0.12 : 0.22,
|
||||||
|
delay: 0,
|
||||||
|
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
|
||||||
|
animations: animations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPreviewToast(_ message: String) {
|
||||||
|
let label = TravelAlbumPreviewToastLabel()
|
||||||
|
label.text = message
|
||||||
|
label.textColor = .white
|
||||||
|
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.backgroundColor = UIColor(white: 0.12, alpha: 0.94)
|
||||||
|
label.layer.cornerRadius = 10
|
||||||
|
label.clipsToBounds = true
|
||||||
|
label.alpha = 0
|
||||||
|
view.addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-110)
|
||||||
|
make.width.lessThanOrEqualToSuperview().inset(32)
|
||||||
|
}
|
||||||
|
UIView.animate(withDuration: 0.18, animations: { label.alpha = 1 }) { _ in
|
||||||
|
UIView.animate(withDuration: 0.2, delay: 1.5, options: .curveEaseIn, animations: { label.alpha = 0 }) { _ in
|
||||||
|
label.removeFromSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closeTapped() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func aiTapped() {
|
||||||
|
guard presentedViewController == nil else { return }
|
||||||
|
guard let project = currentProject else { return }
|
||||||
|
guard let aiRetouchAPI else {
|
||||||
|
showPreviewToast("AI修图功能暂不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let scenicId = scenicIdProvider()
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
showPreviewToast("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let selectedKind = currentAsset?.kind ?? .original
|
||||||
|
guard let workflow = project.aiRetouchWorkflow(albumId: albumId, selectedKind: selectedKind) else {
|
||||||
|
showPreviewToast("当前图片不支持AI修图")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||||
|
viewModel: TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: scenicId,
|
||||||
|
workflow: workflow
|
||||||
|
),
|
||||||
|
api: aiRetouchAPI,
|
||||||
|
onSubmitted: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.showPreviewToast("AI修图任务已提交")
|
||||||
|
self.reloadProjects(showSuccessToast: false, forceRefreshImage: false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
present(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deleteTapped() {
|
||||||
|
guard currentProject != nil, !isDeletingProject else { return }
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "删除整个项目",
|
||||||
|
message: "将同时删除原图及其全部关联图片,是否继续?",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||||
|
self?.deleteCurrentProjectAfterConfirmation()
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户确认后删除当前项目;接口成功后才更新预览数据和位置。
|
||||||
|
func deleteCurrentProjectAfterConfirmation() {
|
||||||
|
guard !isDeletingProject,
|
||||||
|
let project = currentProject,
|
||||||
|
let deletedProjectIndex = projects.firstIndex(where: { $0.id == project.id })
|
||||||
|
else { return }
|
||||||
|
isDeletingProject = true
|
||||||
|
updateDeleteButton()
|
||||||
|
|
||||||
|
Task { [weak self, actionHandler] in
|
||||||
|
let result = await actionHandler.deleteProject(originalMaterialId: project.originalMaterialId)
|
||||||
|
guard let self else { return }
|
||||||
|
switch result {
|
||||||
|
case .success(let message):
|
||||||
|
self.applySuccessfulDeletion(
|
||||||
|
project: project,
|
||||||
|
deletedProjectIndex: deletedProjectIndex,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
case .failure(let message), .unavailable(let message):
|
||||||
|
self.isDeletingProject = false
|
||||||
|
self.updateDeleteButton()
|
||||||
|
self.showPreviewToast(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySuccessfulDeletion(
|
||||||
|
project: TravelAlbumPreviewProject,
|
||||||
|
deletedProjectIndex: Int,
|
||||||
|
message: String?
|
||||||
|
) {
|
||||||
|
guard projects.indices.contains(deletedProjectIndex),
|
||||||
|
projects[deletedProjectIndex].id == project.id
|
||||||
|
else {
|
||||||
|
isDeletingProject = false
|
||||||
|
updateDeleteButton()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projects.remove(at: deletedProjectIndex)
|
||||||
|
totalCount = max(0, totalCount - 1)
|
||||||
|
onProjectDeleted?(project.originalMaterialId)
|
||||||
|
|
||||||
|
guard let targetProjectIndex = TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||||
|
deletedProjectIndex: deletedProjectIndex,
|
||||||
|
remainingProjectCount: projects.count
|
||||||
|
) else {
|
||||||
|
dismiss(animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedKind = .original
|
||||||
|
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: .original)
|
||||||
|
collectionView.reloadData()
|
||||||
|
collectionView.layoutIfNeeded()
|
||||||
|
setPage(currentNodeIndex, animated: false)
|
||||||
|
isDeletingProject = false
|
||||||
|
updateDeleteButton()
|
||||||
|
updateForCurrentNode()
|
||||||
|
if let message, !message.isEmpty {
|
||||||
|
showPreviewToast(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateDeleteButton() {
|
||||||
|
deleteButton.isEnabled = !isDeletingProject
|
||||||
|
deleteButton.alpha = isDeletingProject ? 0.55 : 1
|
||||||
|
deleteButton.accessibilityValue = isDeletingProject ? "删除中" : nil
|
||||||
|
var configuration = deleteButton.configuration
|
||||||
|
configuration?.showsActivityIndicator = isDeletingProject
|
||||||
|
configuration?.image = isDeletingProject ? nil : UIImage(systemName: "trash")
|
||||||
|
configuration?.title = isDeletingProject ? "删除中" : "删除"
|
||||||
|
deleteButton.configuration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshTapped() {
|
||||||
|
reloadProjects(showSuccessToast: true, forceRefreshImage: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadProjects(showSuccessToast: Bool, forceRefreshImage: Bool) {
|
||||||
|
guard !isRefreshingProject else { return }
|
||||||
|
guard let reload else {
|
||||||
|
if showSuccessToast { showPreviewToast("关联图片刷新接口待接入") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isRefreshingProject = true
|
||||||
|
updateRefreshButton()
|
||||||
|
let currentProjectId = currentProject?.id
|
||||||
|
let currentProjectIndex = currentNode?.projectIndex ?? 0
|
||||||
|
let kind = currentAsset?.kind ?? .original
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let result = try await reload()
|
||||||
|
self.applySuccessfulReload(
|
||||||
|
projects: result.projects,
|
||||||
|
totalCount: result.totalCount,
|
||||||
|
currentProjectId: currentProjectId,
|
||||||
|
fallbackProjectIndex: currentProjectIndex,
|
||||||
|
kind: kind,
|
||||||
|
showSuccessToast: showSuccessToast,
|
||||||
|
forceRefreshImage: forceRefreshImage
|
||||||
|
)
|
||||||
|
} catch is CancellationError {
|
||||||
|
self.isRefreshingProject = false
|
||||||
|
self.updateRefreshButton()
|
||||||
|
} catch {
|
||||||
|
self.isRefreshingProject = false
|
||||||
|
self.updateRefreshButton()
|
||||||
|
if showSuccessToast {
|
||||||
|
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.showPreviewToast(message.isEmpty ? "刷新失败" : message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySuccessfulReload(
|
||||||
|
projects refreshedProjects: [TravelAlbumPreviewProject],
|
||||||
|
totalCount: Int,
|
||||||
|
currentProjectId: Int?,
|
||||||
|
fallbackProjectIndex: Int,
|
||||||
|
kind: TravelAlbumPreviewAssetKind,
|
||||||
|
showSuccessToast: Bool,
|
||||||
|
forceRefreshImage: Bool
|
||||||
|
) {
|
||||||
|
let incoming = Self.deduplicated(refreshedProjects)
|
||||||
|
guard !incoming.isEmpty else {
|
||||||
|
isRefreshingProject = false
|
||||||
|
updateRefreshButton()
|
||||||
|
if showSuccessToast { dismiss(animated: true) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
projects = incoming
|
||||||
|
self.totalCount = max(totalCount, incoming.count)
|
||||||
|
let targetProjectIndex = currentProjectId.flatMap { id in
|
||||||
|
incoming.firstIndex { $0.id == id }
|
||||||
|
} ?? min(max(0, fallbackProjectIndex), incoming.count - 1)
|
||||||
|
let resolvedKind = incoming[targetProjectIndex].asset(for: kind) == nil ? .original : kind
|
||||||
|
selectedKind = resolvedKind
|
||||||
|
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind)
|
||||||
|
collectionView.reloadData()
|
||||||
|
collectionView.layoutIfNeeded()
|
||||||
|
setPage(currentNodeIndex, animated: false)
|
||||||
|
updateForCurrentNode()
|
||||||
|
if forceRefreshImage { forceRefreshCurrentImage() }
|
||||||
|
isRefreshingProject = false
|
||||||
|
updateRefreshButton()
|
||||||
|
if showSuccessToast { showPreviewToast("刷新成功") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forceRefreshCurrentImage() {
|
||||||
|
let indexPath = IndexPath(item: currentNodeIndex, section: 0)
|
||||||
|
guard let cell = collectionView.cellForItem(at: indexPath) as? TravelAlbumPreviewImageCell else { return }
|
||||||
|
cell.apply(asset: currentAsset, forceRefresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRefreshButton() {
|
||||||
|
refreshButton.isEnabled = !isRefreshingProject
|
||||||
|
refreshButton.alpha = isRefreshingProject ? 0.55 : 1
|
||||||
|
refreshButton.accessibilityValue = isRefreshingProject ? "刷新中" : nil
|
||||||
|
var configuration = refreshButton.configuration
|
||||||
|
configuration?.showsActivityIndicator = isRefreshingProject
|
||||||
|
configuration?.image = isRefreshingProject ? nil : UIImage(systemName: "arrow.clockwise")
|
||||||
|
configuration?.title = isRefreshingProject ? "刷新中" : "刷新"
|
||||||
|
refreshButton.configuration = configuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
|
nodes.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(
|
||||||
|
_ collectionView: UICollectionView,
|
||||||
|
cellForItemAt indexPath: IndexPath
|
||||||
|
) -> UICollectionViewCell {
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumPreviewImageCell
|
||||||
|
let node = nodes[indexPath.item]
|
||||||
|
let project = projects[node.projectIndex]
|
||||||
|
let kind = configuration.swipeMode == .projectsOnly && indexPath.item == currentNodeIndex
|
||||||
|
? selectedKind
|
||||||
|
: node.kind
|
||||||
|
cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original))
|
||||||
|
cell.onSingleTap = { [weak self] in self?.toggleChrome() }
|
||||||
|
cell.onZoomChanged = { [weak self] zoomed in
|
||||||
|
self?.collectionView.isScrollEnabled = !zoomed
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(
|
||||||
|
_ collectionView: UICollectionView,
|
||||||
|
layout collectionViewLayout: UICollectionViewLayout,
|
||||||
|
sizeForItemAt indexPath: IndexPath
|
||||||
|
) -> CGSize {
|
||||||
|
collectionView.bounds.size
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
dragStartIndex = currentNodeIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewWillEndDragging(
|
||||||
|
_ scrollView: UIScrollView,
|
||||||
|
withVelocity velocity: CGPoint,
|
||||||
|
targetContentOffset: UnsafeMutablePointer<CGPoint>
|
||||||
|
) {
|
||||||
|
guard configuration.swipeMode == .includeVariants, scrollView.bounds.width > 0 else { return }
|
||||||
|
let proposed = Int(round(targetContentOffset.pointee.x / scrollView.bounds.width))
|
||||||
|
guard proposed < dragStartIndex else { return }
|
||||||
|
let target = TravelAlbumPreviewNavigator.backwardTargetIndex(nodes: nodes, currentIndex: dragStartIndex)
|
||||||
|
targetContentOffset.pointee.x = CGFloat(target) * scrollView.bounds.width
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
updatePageFromOffset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||||
|
updatePageFromOffset()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updatePageFromOffset() {
|
||||||
|
guard collectionView.bounds.width > 0 else { return }
|
||||||
|
let index = Int(round(collectionView.contentOffset.x / collectionView.bounds.width))
|
||||||
|
didChangePage(to: min(max(0, index), max(0, nodes.count - 1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
||||||
|
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||||
|
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
||||||
|
var onSingleTap: (() -> Void)?
|
||||||
|
var onZoomChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let retryButton = UIButton(type: .system)
|
||||||
|
private var asset: TravelAlbumPreviewAsset?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
imageView.kf.cancelDownloadTask()
|
||||||
|
imageView.image = nil
|
||||||
|
asset = nil
|
||||||
|
retryButton.isHidden = true
|
||||||
|
resetZoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(asset newAsset: TravelAlbumPreviewAsset?, forceRefresh: Bool = false) {
|
||||||
|
asset = newAsset
|
||||||
|
resetZoom()
|
||||||
|
loadImage(forceRefresh: forceRefresh)
|
||||||
|
accessibilityLabel = newAsset.map {
|
||||||
|
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetZoom() {
|
||||||
|
scrollView.setZoomScale(1, animated: false)
|
||||||
|
scrollView.contentOffset = .zero
|
||||||
|
scrollView.panGestureRecognizer.isEnabled = false
|
||||||
|
onZoomChanged?(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||||
|
imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||||
|
centerImage()
|
||||||
|
let isZoomed = scrollView.zoomScale > 1.01
|
||||||
|
if scrollView.panGestureRecognizer.isEnabled != isZoomed {
|
||||||
|
scrollView.panGestureRecognizer.isEnabled = isZoomed
|
||||||
|
}
|
||||||
|
onZoomChanged?(isZoomed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .black
|
||||||
|
scrollView.backgroundColor = .black
|
||||||
|
scrollView.delegate = self
|
||||||
|
scrollView.minimumZoomScale = 1
|
||||||
|
scrollView.maximumZoomScale = 4
|
||||||
|
scrollView.showsHorizontalScrollIndicator = false
|
||||||
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
|
scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
|
scrollView.panGestureRecognizer.isEnabled = false
|
||||||
|
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
imageView.backgroundColor = .black
|
||||||
|
imageView.kf.indicatorType = .activity
|
||||||
|
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
||||||
|
|
||||||
|
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
||||||
|
retryButton.setTitleColor(UIColor.white.withAlphaComponent(0.82), for: .normal)
|
||||||
|
retryButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
retryButton.isHidden = true
|
||||||
|
retryButton.accessibilityLabel = "图片加载失败,重新加载"
|
||||||
|
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
contentView.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(imageView)
|
||||||
|
contentView.addSubview(retryButton)
|
||||||
|
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
imageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(scrollView.contentLayoutGuide)
|
||||||
|
make.width.equalTo(scrollView.frameLayoutGuide)
|
||||||
|
make.height.equalTo(scrollView.frameLayoutGuide)
|
||||||
|
}
|
||||||
|
retryButton.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||||
|
|
||||||
|
let singleTap = UITapGestureRecognizer(target: self, action: #selector(singleTapped))
|
||||||
|
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(doubleTapped(_:)))
|
||||||
|
doubleTap.numberOfTapsRequired = 2
|
||||||
|
singleTap.require(toFail: doubleTap)
|
||||||
|
scrollView.addGestureRecognizer(singleTap)
|
||||||
|
scrollView.addGestureRecognizer(doubleTap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImage(forceRefresh: Bool = false) {
|
||||||
|
retryButton.isHidden = true
|
||||||
|
let text = asset?.displayURL
|
||||||
|
guard let text, let url = URL(string: text), !text.isEmpty else {
|
||||||
|
imageView.image = nil
|
||||||
|
retryButton.isHidden = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let options: KingfisherOptionsInfo? = forceRefresh ? [.forceRefresh] : nil
|
||||||
|
imageView.kf.setImage(with: url, options: options) { [weak self] result in
|
||||||
|
guard let self, self.asset?.displayURL == text else { return }
|
||||||
|
if case .failure = result, self.imageView.image == nil {
|
||||||
|
self.retryButton.isHidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func centerImage() {
|
||||||
|
let horizontal = max(0, (scrollView.bounds.width - imageView.frame.width) / 2)
|
||||||
|
let vertical = max(0, (scrollView.bounds.height - imageView.frame.height) / 2)
|
||||||
|
scrollView.contentInset = UIEdgeInsets(top: vertical, left: horizontal, bottom: vertical, right: horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func singleTapped() {
|
||||||
|
onSingleTap?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func doubleTapped(_ gesture: UITapGestureRecognizer) {
|
||||||
|
if scrollView.zoomScale > 1.01 {
|
||||||
|
scrollView.setZoomScale(1, animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let point = gesture.location(in: imageView)
|
||||||
|
let width = scrollView.bounds.width / 2
|
||||||
|
let height = scrollView.bounds.height / 2
|
||||||
|
scrollView.zoom(to: CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func retryTapped() {
|
||||||
|
loadImage(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 带内边距的轻量 Toast 标签。
|
||||||
|
private final class TravelAlbumPreviewToastLabel: UILabel {
|
||||||
|
private let insets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14)
|
||||||
|
|
||||||
|
override func drawText(in rect: CGRect) {
|
||||||
|
super.drawText(in: rect.inset(by: insets))
|
||||||
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
let size = super.intrinsicContentSize
|
||||||
|
return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ final class AuthModelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(response.token, "person-temp-token")
|
XCTAssertEqual(response.token, "person-temp-token")
|
||||||
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
|
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
|
||||||
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
|
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
|
||||||
|
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
|
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
|
||||||
@@ -137,4 +138,39 @@ final class AuthModelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
|
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
|
||||||
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
|
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"token": "person-temp-token",
|
||||||
|
"scenic_users": [
|
||||||
|
{
|
||||||
|
"account_type": "scenic_user",
|
||||||
|
"ss_user_id": 101,
|
||||||
|
"username": "backend_scenic_account",
|
||||||
|
"user_name": "backend_scenic_unique_id",
|
||||||
|
"real_name": "景区真实姓名"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"store_users": [
|
||||||
|
{
|
||||||
|
"account_type": "store_user",
|
||||||
|
"store_user_id": 201,
|
||||||
|
"username": "backend_store_account",
|
||||||
|
"user_name": "backend_store_unique_id",
|
||||||
|
"real_name": "门店真实姓名"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let response = try JSONDecoder().decode(V9AuthResponse.self, from: json)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.scenicUsers.first?.realName, "景区真实姓名")
|
||||||
|
XCTAssertEqual(response.scenicUsers.first?.displayName, "景区真实姓名")
|
||||||
|
XCTAssertEqual(response.scenicUsers.first?.toAccountSwitchAccount().title, "景区真实姓名")
|
||||||
|
XCTAssertEqual(response.storeUsers.first?.realName, "门店真实姓名")
|
||||||
|
XCTAssertEqual(response.storeUsers.first?.displayName, "门店真实姓名")
|
||||||
|
XCTAssertEqual(response.storeUsers.first?.toAccountSwitchAccount().title, "门店真实姓名")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(viewModel.showAmountDialog)
|
XCTAssertFalse(viewModel.showAmountDialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPayCodeLoadingStateCoversInitialRenderAndRequest() async {
|
||||||
|
let payCodeJSON = """
|
||||||
|
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let session = MockURLSession(responses: [payCodeJSON])
|
||||||
|
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||||
|
var loadingStates = [viewModel.isPayCodeLoading]
|
||||||
|
viewModel.onStateChange = {
|
||||||
|
loadingStates.append(viewModel.isPayCodeLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewModel.loadPayCode(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(loadingStates.first, true)
|
||||||
|
XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 })
|
||||||
|
XCTAssertEqual(loadingStates.last, false)
|
||||||
|
XCTAssertTrue(viewModel.hasPayCode)
|
||||||
|
}
|
||||||
|
|
||||||
func testToggleReceiveVoicePersistsInAppStore() {
|
func testToggleReceiveVoicePersistsInAppStore() {
|
||||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||||
viewModel.refreshLocalState()
|
viewModel.refreshLocalState()
|
||||||
@@ -79,8 +99,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
|
XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() {
|
func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() {
|
||||||
appStore.session.userName = "摄影师小王"
|
appStore.session.userName = "摄影师昵称"
|
||||||
|
appStore.session.realName = "摄影师真实姓名"
|
||||||
appStore.permissions.saveRolePermissionList([
|
appStore.permissions.saveRolePermissionList([
|
||||||
RolePermissionResponse(
|
RolePermissionResponse(
|
||||||
role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"),
|
role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"),
|
||||||
@@ -93,10 +114,19 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.displayPhotographerName, "摄影师小王")
|
XCTAssertEqual(viewModel.displayPhotographerName, "摄影师真实姓名")
|
||||||
XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店")
|
XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() {
|
||||||
|
appStore.session.userName = "摄影师昵称"
|
||||||
|
appStore.session.realName = " "
|
||||||
|
|
||||||
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.displayPhotographerName, "-")
|
||||||
|
}
|
||||||
|
|
||||||
func testLoadPayCodeClearsQRWhenScenicMissing() async {
|
func testLoadPayCodeClearsQRWhenScenicMissing() async {
|
||||||
appStore.session.currentScenicId = 0
|
appStore.session.currentScenicId = 0
|
||||||
let session = MockURLSession(responses: [])
|
let session = MockURLSession(responses: [])
|
||||||
@@ -105,6 +135,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
await viewModel.loadPayCode(api: api)
|
await viewModel.loadPayCode(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.isPayCodeLoading)
|
||||||
XCTAssertFalse(viewModel.hasPayCode)
|
XCTAssertFalse(viewModel.hasPayCode)
|
||||||
XCTAssertEqual(session.requests.count, 0)
|
XCTAssertEqual(session.requests.count, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,11 +55,12 @@ final class PaymentModelsTests: XCTestCase {
|
|||||||
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
|
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() {
|
func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"),
|
PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
|
||||||
"当前摄影师"
|
"摄影师真实姓名"
|
||||||
)
|
)
|
||||||
|
XCTAssertEqual(PaymentAccountDisplayFormatter.photographerName(realName: " "), "-")
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
|
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
|
||||||
"一号店、二号店"
|
"一号店、二号店"
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ final class ProfileViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
|
XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testColdStartBasicInfoUsesPersistedSessionCache() {
|
||||||
|
AppStore.shared.session.userName = "缓存昵称"
|
||||||
|
AppStore.shared.session.realName = "缓存真实姓名"
|
||||||
|
AppStore.shared.session.phone = "186****7230"
|
||||||
|
AppStore.shared.session.avatar = "https://cdn.example.com/cached-avatar.jpg"
|
||||||
|
|
||||||
|
let viewModel = ProfileViewModel()
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.hasCachedBasicInfo)
|
||||||
|
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
|
||||||
|
XCTAssertEqual(viewModel.displayRealName, "缓存真实姓名")
|
||||||
|
XCTAssertEqual(viewModel.displayPhone, "186****7230")
|
||||||
|
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/cached-avatar.jpg")
|
||||||
|
XCTAssertEqual(viewModel.displayUID, "profile-user")
|
||||||
|
}
|
||||||
|
|
||||||
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
|
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
|
||||||
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
|
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
|
||||||
let viewModel = ProfileViewModel()
|
let viewModel = ProfileViewModel()
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//
|
||||||
|
// SessionExpiredDialogViewControllerTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 登录失效弹窗测试,覆盖强制展示配置、Android 对齐文案与确认防重。
|
||||||
|
@MainActor
|
||||||
|
final class SessionExpiredDialogViewControllerTests: XCTestCase {
|
||||||
|
|
||||||
|
func testDialogUsesForcedPresentationAndAndroidCopy() throws {
|
||||||
|
let dialog = SessionExpiredDialogViewController(onConfirm: {})
|
||||||
|
dialog.loadViewIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertEqual(dialog.modalPresentationStyle, .overFullScreen)
|
||||||
|
XCTAssertEqual(dialog.modalTransitionStyle, .crossDissolve)
|
||||||
|
XCTAssertTrue(dialog.isModalInPresentation)
|
||||||
|
XCTAssertTrue(dialog.view.accessibilityViewIsModal)
|
||||||
|
|
||||||
|
let labels = dialog.view.allSubviews(of: UILabel.self)
|
||||||
|
XCTAssertTrue(labels.contains { $0.text == "登录提醒" })
|
||||||
|
XCTAssertTrue(labels.contains { $0.text == "当前用户已在其他设备登录,\n请重新登录" })
|
||||||
|
|
||||||
|
let button = try XCTUnwrap(
|
||||||
|
dialog.view.allSubviews(of: UIButton.self).first {
|
||||||
|
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertEqual(button.title(for: .normal), "确认并退出登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfirmCallbackOnlyRunsOnce() throws {
|
||||||
|
var confirmationCount = 0
|
||||||
|
let dialog = SessionExpiredDialogViewController {
|
||||||
|
confirmationCount += 1
|
||||||
|
}
|
||||||
|
dialog.loadViewIfNeeded()
|
||||||
|
let button = try XCTUnwrap(
|
||||||
|
dialog.view.allSubviews(of: UIButton.self).first {
|
||||||
|
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
button.sendActions(for: .touchUpInside)
|
||||||
|
button.sendActions(for: .touchUpInside)
|
||||||
|
|
||||||
|
XCTAssertEqual(confirmationCount, 1)
|
||||||
|
XCTAssertFalse(button.isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIView {
|
||||||
|
func allSubviews<View: UIView>(of type: View.Type) -> [View] {
|
||||||
|
subviews.flatMap { subview -> [View] in
|
||||||
|
let current = (subview as? View).map { [$0] } ?? []
|
||||||
|
return current + subview.allSubviews(of: type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumAIRetouchTemplateViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// AI 修图模板工作流、选择状态与请求分流测试。
|
||||||
|
@MainActor
|
||||||
|
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||||
|
func testInitialWorkflowDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 8,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [4, 3, 2, 1, 1]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
|
||||||
|
XCTAssertEqual(viewModel.workflow, .initial(albumId: 8, materialIds: [1, 2, 3, 4]))
|
||||||
|
XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover])
|
||||||
|
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
||||||
|
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
||||||
|
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
|
||||||
|
XCTAssertTrue(viewModel.isOptional(.atmosphere))
|
||||||
|
XCTAssertTrue(viewModel.canSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialAtmosphereSelectionTogglesOffWhenTappedAgain() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 8,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [1]
|
||||||
|
)
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
|
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
|
||||||
|
|
||||||
|
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialSubmissionUsesCoverTemplateOnlyForFourOrMoreMaterials() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let threePhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 8,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [3, 1, 2]
|
||||||
|
)
|
||||||
|
await threePhotoViewModel.loadTemplates(api: api)
|
||||||
|
await threePhotoViewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
api.aiRetouchRequests[0],
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: 8,
|
||||||
|
materialIds: [1, 2, 3],
|
||||||
|
refinedTemplateId: 11,
|
||||||
|
atmosphereTemplateId: nil,
|
||||||
|
coverTemplateId: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let fourPhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 8,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [1, 2, 3, 4]
|
||||||
|
)
|
||||||
|
await fourPhotoViewModel.loadTemplates(api: api)
|
||||||
|
fourPhotoViewModel.toggleTemplate(id: 12, category: .refined)
|
||||||
|
fourPhotoViewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
await fourPhotoViewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
api.aiRetouchRequests[1],
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: 8,
|
||||||
|
materialIds: [1, 2, 3, 4],
|
||||||
|
refinedTemplateId: 12,
|
||||||
|
atmosphereTemplateId: 21,
|
||||||
|
coverTemplateId: 31
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReretouchWorkflowShowsAndSubmitsOnlyRequiredCategories() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let refined = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
||||||
|
)
|
||||||
|
await refined.loadTemplates(api: api)
|
||||||
|
XCTAssertEqual(refined.visibleCategories, [.refined])
|
||||||
|
XCTAssertEqual(refined.selectedRefinedTemplateId, 11)
|
||||||
|
XCTAssertNil(refined.selectedAtmosphereTemplateId)
|
||||||
|
await refined.submit(api: api)
|
||||||
|
|
||||||
|
let atmosphere = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 8, batchId: 80, type: .atmosphere)
|
||||||
|
)
|
||||||
|
await atmosphere.loadTemplates(api: api)
|
||||||
|
XCTAssertEqual(atmosphere.visibleCategories, [.atmosphere])
|
||||||
|
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
|
||||||
|
atmosphere.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
|
||||||
|
await atmosphere.submit(api: api)
|
||||||
|
|
||||||
|
let all = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 9, batchId: 90, type: .all)
|
||||||
|
)
|
||||||
|
await all.loadTemplates(api: api)
|
||||||
|
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
|
||||||
|
XCTAssertEqual(all.selectedRefinedTemplateId, 11)
|
||||||
|
XCTAssertEqual(all.selectedAtmosphereTemplateId, 21)
|
||||||
|
await all.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.aiReretouchRequests, [
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 7,
|
||||||
|
aiRetouchBatchId: 70,
|
||||||
|
type: .refined,
|
||||||
|
refinedTemplateId: 11,
|
||||||
|
atmosphereTemplateId: nil
|
||||||
|
),
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 8,
|
||||||
|
aiRetouchBatchId: 80,
|
||||||
|
type: .atmosphere,
|
||||||
|
refinedTemplateId: nil,
|
||||||
|
atmosphereTemplateId: 21
|
||||||
|
),
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 9,
|
||||||
|
aiRetouchBatchId: 90,
|
||||||
|
type: .all,
|
||||||
|
refinedTemplateId: 11,
|
||||||
|
atmosphereTemplateId: 21
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidReretouchBatchDisablesSubmission() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 0, type: .refined)
|
||||||
|
)
|
||||||
|
var message: String?
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "当前图片缺少修图批次,请刷新后重试")
|
||||||
|
XCTAssertEqual(message, "当前图片缺少修图批次,请刷新后重试")
|
||||||
|
XCTAssertTrue(api.aiReretouchRequests.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingRequiredTemplateDisablesMatchingWorkflow() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
|
refinedTemplates: [template(11, "清透")],
|
||||||
|
atmosphereTemplates: [],
|
||||||
|
coverTemplates: []
|
||||||
|
)
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "暂无可用的氛围感修图模板")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
api.aiRetouchTemplatesError = APIError.serverCode(500, "模板服务繁忙")
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 8,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [1]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.loadErrorMessage, "模板服务繁忙")
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
|
XCTAssertTrue(viewModel.refinedTemplates.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmitFailureKeepsSelectionAndAllowsRetry() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
api.submitAIReretouchError = APIError.serverCode(500, "提交服务繁忙")
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
||||||
|
)
|
||||||
|
var message: String?
|
||||||
|
var submitted = false
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
viewModel.onSubmitted = { submitted = true }
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.aiReretouchRequests.count, 1)
|
||||||
|
XCTAssertEqual(message, "提交服务繁忙")
|
||||||
|
XCTAssertFalse(submitted)
|
||||||
|
XCTAssertFalse(viewModel.isSubmitting)
|
||||||
|
XCTAssertTrue(viewModel.canSubmit)
|
||||||
|
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmittingPreventsDuplicateRequest() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
api.submitAIRetouchDelayNanoseconds = 50_000_000
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 8,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [1]
|
||||||
|
)
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
|
let firstSubmission = Task { await viewModel.submit(api: api) }
|
||||||
|
await waitUntil { viewModel.isSubmitting }
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
await firstSubmission.value
|
||||||
|
|
||||||
|
XCTAssertEqual(api.aiRetouchRequests.count, 1)
|
||||||
|
XCTAssertFalse(viewModel.isSubmitting)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAPI() -> TravelAlbumMockAPI {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
|
refinedTemplates: [template(11, "清透"), template(12, "自然")],
|
||||||
|
atmosphereTemplates: [template(21, "暖阳")],
|
||||||
|
coverTemplates: [template(31, "杂志")]
|
||||||
|
)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
private func template(_ id: Int, _ name: String) -> TravelAlbumAIRetouchTemplate {
|
||||||
|
TravelAlbumAIRetouchTemplate(id: id, name: name, previewURL: "https://cdn.example.com/\(id).jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
||||||
|
for _ in 0 ..< 100 {
|
||||||
|
if condition() { return }
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,16 +51,27 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testMaterialListAndDeleteAndMpCode() async throws {
|
func testMaterialListAndDeleteAndMpCode() async throws {
|
||||||
let materialList = envelopeJSON(#"{"total":0,"list":[]}"#)
|
let materialList = envelopeJSON(
|
||||||
|
#"{"total":1,"list":[{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}]}"#
|
||||||
|
)
|
||||||
let empty = envelopeJSON("{}")
|
let empty = envelopeJSON("{}")
|
||||||
let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
|
let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
|
||||||
let session = MockURLSession(responses: [materialList, empty, code])
|
let session = MockURLSession(responses: [materialList, empty, code])
|
||||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
_ = try await api.materialList(userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1)
|
let materials = try await api.materialList(
|
||||||
|
userEquityTravelId: 3,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 30,
|
||||||
|
orderBy: 4,
|
||||||
|
isPurchased: 1
|
||||||
|
)
|
||||||
try await api.deleteAlbum(id: 3)
|
try await api.deleteAlbum(id: 3)
|
||||||
let response = try await api.mpCode(id: 3)
|
let response = try await api.mpCode(id: 3)
|
||||||
|
|
||||||
|
XCTAssertEqual(materials.list.first?.aiRetouchStatus, 3)
|
||||||
|
XCTAssertEqual(materials.list.first?.aiRefinedURL, "https://cdn/refined.jpg")
|
||||||
|
XCTAssertEqual(materials.list.first?.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
|
||||||
XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png")
|
XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png")
|
||||||
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list")
|
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list")
|
||||||
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
|
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
|
||||||
@@ -113,6 +124,138 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
|
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
|
||||||
|
let data = envelopeJSON(
|
||||||
|
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}]}"#
|
||||||
|
)
|
||||||
|
let session = MockURLSession(responses: [data])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.aiRetouchTemplates(scenicId: 18)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
|
||||||
|
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
|
||||||
|
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "GET")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates")
|
||||||
|
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "18")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelopeJSON(#"{"task_id":9}"#)])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
try await api.submitAIRetouch(
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: 6,
|
||||||
|
materialIds: [11, 12, 13, 14],
|
||||||
|
refinedTemplateId: 21,
|
||||||
|
atmosphereTemplateId: 22,
|
||||||
|
coverTemplateId: 31
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "POST")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch")
|
||||||
|
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||||
|
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6)
|
||||||
|
XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14])
|
||||||
|
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
|
||||||
|
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
|
||||||
|
XCTAssertEqual(body?["cover_template_id"] as? Int, 31)
|
||||||
|
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
|
||||||
|
"user_equity_travel_id",
|
||||||
|
"material_ids",
|
||||||
|
"refined_template_id",
|
||||||
|
"atmosphere_template_id",
|
||||||
|
"cover_template_id",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelopeJSON(#"{"accepted":true}"#)])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
try await api.submitAIRetouch(
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: 6,
|
||||||
|
materialIds: [11],
|
||||||
|
refinedTemplateId: 21,
|
||||||
|
atmosphereTemplateId: nil,
|
||||||
|
coverTemplateId: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
|
||||||
|
XCTAssertNil(body?["atmosphere_template_id"])
|
||||||
|
XCTAssertNil(body?["cover_template_id"])
|
||||||
|
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
|
||||||
|
"user_equity_travel_id",
|
||||||
|
"material_ids",
|
||||||
|
"refined_template_id",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmitAIReretouchEncodesOnlyFieldsRequiredByEachType() async throws {
|
||||||
|
let session = MockURLSession(responses: [
|
||||||
|
envelopeJSON(#"{"accepted":true}"#),
|
||||||
|
envelopeJSON(#"{"accepted":true}"#),
|
||||||
|
envelopeJSON(#"{"accepted":true}"#),
|
||||||
|
])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 11,
|
||||||
|
aiRetouchBatchId: 51,
|
||||||
|
type: .refined,
|
||||||
|
refinedTemplateId: 21,
|
||||||
|
atmosphereTemplateId: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 12,
|
||||||
|
aiRetouchBatchId: 52,
|
||||||
|
type: .atmosphere,
|
||||||
|
refinedTemplateId: nil,
|
||||||
|
atmosphereTemplateId: 22
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 13,
|
||||||
|
aiRetouchBatchId: 53,
|
||||||
|
type: .all,
|
||||||
|
refinedTemplateId: 21,
|
||||||
|
atmosphereTemplateId: 22
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let bodies = try session.requests.map { request in
|
||||||
|
try JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||||
|
}
|
||||||
|
XCTAssertEqual(session.requests.map { $0.url?.path }, Array(
|
||||||
|
repeating: "/api/yf-handset-app/photog/travel-album/ai-reretouch",
|
||||||
|
count: 3
|
||||||
|
))
|
||||||
|
XCTAssertEqual(Set(bodies[0]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "refined_template_id"])
|
||||||
|
XCTAssertEqual(Set(bodies[1]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "atmosphere_template_id"])
|
||||||
|
XCTAssertEqual(Set(bodies[2]?.keys.map { $0 } ?? []), [
|
||||||
|
"id",
|
||||||
|
"ai_retouch_batch_id",
|
||||||
|
"type",
|
||||||
|
"refined_template_id",
|
||||||
|
"atmosphere_template_id",
|
||||||
|
])
|
||||||
|
XCTAssertEqual(bodies[0]?["type"] as? Int, 1)
|
||||||
|
XCTAssertEqual(bodies[1]?["type"] as? Int, 2)
|
||||||
|
XCTAssertEqual(bodies[2]?["type"] as? Int, 3)
|
||||||
|
}
|
||||||
|
|
||||||
private func envelopeJSON(_ dataJSON: String) -> Data {
|
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||||
"""
|
"""
|
||||||
{"code":100000,"msg":"success","data":\(dataJSON)}
|
{"code":100000,"msg":"success","data":\(dataJSON)}
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumDetailViewControllerTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 相册管理页刷新、预览与选择态交互测试。
|
||||||
|
@MainActor
|
||||||
|
final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||||
|
func testPullToRefreshReloadsGridAndEndsRefreshing() async throws {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.infoResponse = TravelAlbum(id: 2, name: "测试相册")
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(total: 1, list: []),
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
TravelAlbumListResponse(total: 1, list: [TravelAlbumMaterial(id: 1)]),
|
||||||
|
TravelAlbumListResponse(total: 2, list: []),
|
||||||
|
TravelAlbumListResponse(total: 1, list: []),
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 2,
|
||||||
|
list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let controller = TravelAlbumDetailViewController(albumId: 2, api: api)
|
||||||
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||||
|
window.rootViewController = controller
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer { window.isHidden = true }
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
let collectionView = try XCTUnwrap(
|
||||||
|
controller.view.findSubview { $0 is UICollectionView } as? UICollectionView
|
||||||
|
)
|
||||||
|
let refreshControl = try XCTUnwrap(collectionView.refreshControl)
|
||||||
|
await waitUntil { api.materialRequests.count == 3 }
|
||||||
|
|
||||||
|
refreshControl.beginRefreshing()
|
||||||
|
XCTAssertTrue(refreshControl.isRefreshing)
|
||||||
|
refreshControl.sendActions(for: .valueChanged)
|
||||||
|
await waitUntil {
|
||||||
|
api.materialRequests.count == 6
|
||||||
|
&& !refreshControl.isRefreshing
|
||||||
|
&& collectionView.numberOfItems(inSection: 0) == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(refreshControl.accessibilityIdentifier, "travelAlbum.refreshControl")
|
||||||
|
XCTAssertEqual(collectionView.numberOfItems(inSection: 0), 2)
|
||||||
|
XCTAssertFalse(refreshControl.isRefreshing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewDeleteHandlerCallsMaterialAPIAndPreservesServerFailure() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
let handler = TravelAlbumPreviewActionHandler(api: api)
|
||||||
|
|
||||||
|
let success = await handler.deleteProject(originalMaterialId: 8)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIds, [8])
|
||||||
|
XCTAssertEqual(success, .success("删除成功"))
|
||||||
|
|
||||||
|
api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用")
|
||||||
|
let failure = await handler.deleteProject(originalMaterialId: 9)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIds, [8, 9])
|
||||||
|
XCTAssertEqual(failure, .failure("服务暂不可用"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.deleteMaterialDelayNanoseconds = 1_000_000
|
||||||
|
var deletedIds: [Int] = []
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [
|
||||||
|
makePreviewProject(id: 1, fileName: "第一张.jpg"),
|
||||||
|
makePreviewProject(id: 2, fileName: "第二张.jpg"),
|
||||||
|
makePreviewProject(id: 3, fileName: "第三张.jpg"),
|
||||||
|
],
|
||||||
|
totalCount: 3,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
actionHandler: TravelAlbumPreviewActionHandler(api: api),
|
||||||
|
onProjectDeleted: { deletedIds.append($0) }
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
controller.deleteCurrentProjectAfterConfirmation()
|
||||||
|
controller.deleteCurrentProjectAfterConfirmation()
|
||||||
|
await waitUntil {
|
||||||
|
controller.view.allAccessibilityLabels().contains("第 1 张,共 2 张")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIds, [1])
|
||||||
|
XCTAssertEqual(deletedIds, [1])
|
||||||
|
XCTAssertTrue(
|
||||||
|
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewDeletingLastProjectShowsPreviousProject() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [
|
||||||
|
makePreviewProject(id: 1, fileName: "第一张.jpg"),
|
||||||
|
makePreviewProject(id: 2, fileName: "第二张.jpg"),
|
||||||
|
makePreviewProject(id: 3, fileName: "第三张.jpg"),
|
||||||
|
],
|
||||||
|
totalCount: 3,
|
||||||
|
startProjectIndex: 2,
|
||||||
|
actionHandler: TravelAlbumPreviewActionHandler(api: api)
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
controller.deleteCurrentProjectAfterConfirmation()
|
||||||
|
await waitUntil {
|
||||||
|
controller.view.allAccessibilityLabels().contains("第 2 张,共 2 张")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIds, [3])
|
||||||
|
XCTAssertTrue(
|
||||||
|
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewDeletingOnlyProjectDismissesPage() async {
|
||||||
|
UIView.setAnimationsEnabled(false)
|
||||||
|
defer { UIView.setAnimationsEnabled(true) }
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
let host = UIViewController()
|
||||||
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||||
|
window.rootViewController = host
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [makePreviewProject(id: 1, fileName: "仅有一张.jpg")],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
actionHandler: TravelAlbumPreviewActionHandler(api: api)
|
||||||
|
)
|
||||||
|
host.present(controller, animated: false)
|
||||||
|
await waitUntil { controller.presentingViewController === host }
|
||||||
|
|
||||||
|
controller.deleteCurrentProjectAfterConfirmation()
|
||||||
|
await waitUntil { host.presentedViewController == nil }
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIds, [1])
|
||||||
|
XCTAssertNil(host.presentedViewController)
|
||||||
|
window.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewImageViewKeepsViewportConstraintsWhenSwitchingVariant() throws {
|
||||||
|
UIView.setAnimationsEnabled(false)
|
||||||
|
defer { UIView.setAnimationsEnabled(true) }
|
||||||
|
let project = TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: 1,
|
||||||
|
assets: [
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "original-1",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "原图.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
),
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "retouched-1",
|
||||||
|
kind: .retouched,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "精修后.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [project],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let retouchedButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
($0 as? UIButton)?.accessibilityLabel == "精修后"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
retouchedButton.sendActions(for: .touchUpInside)
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let imageView = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewImageView"
|
||||||
|
} as? UIImageView
|
||||||
|
)
|
||||||
|
let scrollView = try XCTUnwrap(imageView.superview as? UIScrollView)
|
||||||
|
scrollView.layoutIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertEqual(imageView.bounds.size, scrollView.bounds.size)
|
||||||
|
XCTAssertFalse(imageView.constraintsAffectingLayout(for: .horizontal).isEmpty)
|
||||||
|
XCTAssertFalse(imageView.constraintsAffectingLayout(for: .vertical).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectionModeShowsAIRetouchOnLeftAndDeleteOnRight() async throws {
|
||||||
|
let controller = TravelAlbumDetailViewController(albumId: 0)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
|
||||||
|
let selectButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
($0 as? UIButton)?.accessibilityLabel == "选择照片"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
selectButton.sendActions(for: .touchUpInside)
|
||||||
|
await Task.yield()
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let actionStack = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.selectionActionStack"
|
||||||
|
} as? UIStackView
|
||||||
|
)
|
||||||
|
let aiRetouchButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
let deleteButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.deleteButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(actionStack.isHidden)
|
||||||
|
XCTAssertEqual(actionStack.arrangedSubviews, [aiRetouchButton, deleteButton])
|
||||||
|
XCTAssertEqual(actionStack.distribution, .fillEqually)
|
||||||
|
XCTAssertEqual(actionStack.spacing, 12)
|
||||||
|
XCTAssertEqual(aiRetouchButton.configuration?.title, "AI修图")
|
||||||
|
XCTAssertEqual(deleteButton.configuration?.title, "删除")
|
||||||
|
XCTAssertEqual(aiRetouchButton.bounds.width, deleteButton.bounds.width, accuracy: 0.5)
|
||||||
|
XCTAssertLessThan(aiRetouchButton.frame.minX, deleteButton.frame.minX)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAIRetouchTemplateSheetUsesLargeDetentAndFixedBottomActions() async throws {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
|
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||||||
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||||||
|
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")]
|
||||||
|
)
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 9,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [1, 2, 3, 4]
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||||
|
viewModel: viewModel,
|
||||||
|
api: api,
|
||||||
|
onSubmitted: {}
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let sheet = try XCTUnwrap(controller.sheetPresentationController)
|
||||||
|
XCTAssertEqual(sheet.detents.count, 1)
|
||||||
|
XCTAssertEqual(sheet.selectedDetentIdentifier, .large)
|
||||||
|
XCTAssertTrue(sheet.prefersGrabberVisible)
|
||||||
|
|
||||||
|
let collectionView = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
|
||||||
|
} as? UICollectionView
|
||||||
|
)
|
||||||
|
let bottomBar = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchBottomBar"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let cancelButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchCancelButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
let confirmButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
await waitUntil { confirmButton.isEnabled }
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout)
|
||||||
|
XCTAssertFalse(bottomBar.isDescendant(of: collectionView))
|
||||||
|
XCTAssertEqual(cancelButton.configuration?.title, "取消")
|
||||||
|
XCTAssertEqual(confirmButton.configuration?.title, "确定")
|
||||||
|
XCTAssertEqual(cancelButton.bounds.width, confirmButton.bounds.width, accuracy: 0.5)
|
||||||
|
XCTAssertTrue(confirmButton.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAIRetouchSheetShowsOptionalAtmosphereCoverAndModeCopy() async throws {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
|
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||||||
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||||||
|
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")]
|
||||||
|
)
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 9,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [1, 2, 3, 4]
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||||
|
viewModel: viewModel,
|
||||||
|
api: api,
|
||||||
|
onSubmitted: {}
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let accessibleLabels = controller.view.allAccessibilityLabels()
|
||||||
|
XCTAssertTrue(accessibleLabels.contains("原图精修"))
|
||||||
|
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||||||
|
XCTAssertTrue(accessibleLabels.contains("封面风格模板"))
|
||||||
|
|
||||||
|
let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero)
|
||||||
|
modeCell.apply()
|
||||||
|
XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAIRetouchTemplateCellExposesSelectedState() {
|
||||||
|
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
|
||||||
|
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
|
||||||
|
|
||||||
|
cell.apply(template: template, selected: true)
|
||||||
|
|
||||||
|
XCTAssertEqual(cell.contentView.layer.borderWidth, 2)
|
||||||
|
XCTAssertTrue(cell.accessibilityTraits.contains(.selected))
|
||||||
|
XCTAssertEqual(cell.accessibilityValue, "已选择")
|
||||||
|
|
||||||
|
cell.apply(template: template, selected: false)
|
||||||
|
|
||||||
|
XCTAssertEqual(cell.contentView.layer.borderWidth, 1)
|
||||||
|
XCTAssertFalse(cell.accessibilityTraits.contains(.selected))
|
||||||
|
XCTAssertEqual(cell.accessibilityValue, "未选择")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
|
||||||
|
UIView.setAnimationsEnabled(false)
|
||||||
|
defer { UIView.setAnimationsEnabled(true) }
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
|
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "")],
|
||||||
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")]
|
||||||
|
)
|
||||||
|
let project = TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: 7,
|
||||||
|
aiRetouchBatchId: 70,
|
||||||
|
assets: [
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "original-7",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "原图.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
),
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "retouched-7",
|
||||||
|
kind: .retouched,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "精修后.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [project],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
albumId: 8,
|
||||||
|
scenicIdProvider: { 18 },
|
||||||
|
aiRetouchAPI: api
|
||||||
|
)
|
||||||
|
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||||
|
window.rootViewController = controller
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer { window.isHidden = true }
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let retouchedButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "精修后" } as? UIButton
|
||||||
|
)
|
||||||
|
retouchedButton.sendActions(for: .touchUpInside)
|
||||||
|
let aiButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "AI修图" } as? UIButton
|
||||||
|
)
|
||||||
|
aiButton.sendActions(for: .touchUpInside)
|
||||||
|
await waitUntil { controller.presentedViewController is TravelAlbumAIRetouchTemplateViewController }
|
||||||
|
let sheet = try XCTUnwrap(controller.presentedViewController as? TravelAlbumAIRetouchTemplateViewController)
|
||||||
|
sheet.loadViewIfNeeded()
|
||||||
|
let confirmButton = try XCTUnwrap(
|
||||||
|
sheet.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
await waitUntil { api.aiRetouchTemplateScenicIds.count == 1 && confirmButton.isEnabled }
|
||||||
|
sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
sheet.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let labels = sheet.view.allAccessibilityLabels()
|
||||||
|
XCTAssertTrue(labels.contains("原图精修"))
|
||||||
|
XCTAssertFalse(labels.contains { $0.contains("氛围感修图") })
|
||||||
|
confirmButton.sendActions(for: .touchUpInside)
|
||||||
|
await waitUntil { api.aiReretouchRequests.count == 1 }
|
||||||
|
await waitUntil { controller.presentedViewController == nil }
|
||||||
|
|
||||||
|
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
|
||||||
|
XCTAssertNil(controller.presentedViewController)
|
||||||
|
XCTAssertTrue(window.rootViewController === controller)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewRefreshPreservesVariantAndFallsBackWhenVariantDisappears() async throws {
|
||||||
|
let original = TravelAlbumPreviewAsset(
|
||||||
|
id: "original-7",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "原图.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
)
|
||||||
|
let retouched = TravelAlbumPreviewAsset(
|
||||||
|
id: "retouched-7",
|
||||||
|
kind: .retouched,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "精修后.jpg",
|
||||||
|
fileSize: 0
|
||||||
|
)
|
||||||
|
var reloadCount = 0
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original, retouched])],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
reload: {
|
||||||
|
reloadCount += 1
|
||||||
|
let assets = reloadCount == 1 ? [original, retouched] : [original]
|
||||||
|
return ([TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets)], 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
let retouchedButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "精修后" } as? UIButton
|
||||||
|
)
|
||||||
|
retouchedButton.sendActions(for: .touchUpInside)
|
||||||
|
let refreshButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewRefreshButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshButton.sendActions(for: .touchUpInside)
|
||||||
|
await waitUntil { reloadCount == 1 && refreshButton.isEnabled }
|
||||||
|
XCTAssertTrue(
|
||||||
|
controller.view.findSubview {
|
||||||
|
($0 as? UIButton)?.accessibilityLabel == "精修后"
|
||||||
|
&& $0.accessibilityTraits.contains(.selected)
|
||||||
|
} != nil
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshButton.sendActions(for: .touchUpInside)
|
||||||
|
await waitUntil { reloadCount == 2 && refreshButton.isEnabled }
|
||||||
|
XCTAssertNil(
|
||||||
|
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "精修后" }
|
||||||
|
)
|
||||||
|
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图.jpg" })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
||||||
|
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
||||||
|
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
||||||
|
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
|
||||||
|
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
|
||||||
|
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
|
||||||
|
(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面"),
|
||||||
|
"AI封面",
|
||||||
|
0x6D28D9
|
||||||
|
),
|
||||||
|
(TravelAlbumMaterial(aiRetouchStatus: 4), "失败", 0xB91C1C),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (material, expectedText, expectedColor) in cases {
|
||||||
|
let cell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||||
|
cell.apply(material: material, selectionMode: true, selected: false)
|
||||||
|
cell.layoutIfNeeded()
|
||||||
|
|
||||||
|
let badgeView = try XCTUnwrap(
|
||||||
|
cell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let badgeLabel = try XCTUnwrap(
|
||||||
|
cell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel"
|
||||||
|
} as? UILabel
|
||||||
|
)
|
||||||
|
let selectionCheck = try XCTUnwrap(
|
||||||
|
cell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialSelectionCheck"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(badgeView.isHidden)
|
||||||
|
XCTAssertEqual(badgeLabel.text, expectedText)
|
||||||
|
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
|
||||||
|
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
|
||||||
|
XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||||
|
hiddenCell.apply(
|
||||||
|
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
|
||||||
|
selectionMode: false,
|
||||||
|
selected: false
|
||||||
|
)
|
||||||
|
let hiddenBadge = try XCTUnwrap(
|
||||||
|
hiddenCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertTrue(hiddenBadge.isHidden)
|
||||||
|
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
||||||
|
for _ in 0 ..< 200 {
|
||||||
|
if condition() { return }
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePreviewProject(id: Int, fileName: String) -> TravelAlbumPreviewProject {
|
||||||
|
TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: id,
|
||||||
|
assets: [
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "original-\(id)",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: "",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: 0
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIColor {
|
||||||
|
var travelAlbumTestHexRGB: UInt {
|
||||||
|
var red: CGFloat = 0
|
||||||
|
var green: CGFloat = 0
|
||||||
|
var blue: CGFloat = 0
|
||||||
|
var alpha: CGFloat = 0
|
||||||
|
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||||
|
return UInt(round(red * 255)) << 16
|
||||||
|
| UInt(round(green * 255)) << 8
|
||||||
|
| UInt(round(blue * 255))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension UIView {
|
||||||
|
func findSubview(where predicate: (UIView) -> Bool) -> UIView? {
|
||||||
|
if predicate(self) { return self }
|
||||||
|
for subview in subviews {
|
||||||
|
if let match = subview.findSubview(where: predicate) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func allAccessibilityLabels() -> [String] {
|
||||||
|
let current = accessibilityLabel.map { [$0] } ?? []
|
||||||
|
return current + subviews.flatMap { $0.allAccessibilityLabels() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func allLabels() -> [UILabel] {
|
||||||
|
let current = (self as? UILabel).map { [$0] } ?? []
|
||||||
|
return current + subviews.flatMap { $0.allLabels() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,197 @@ import XCTest
|
|||||||
|
|
||||||
/// 旅拍相册模型与展示工具测试。
|
/// 旅拍相册模型与展示工具测试。
|
||||||
final class TravelAlbumModelsTests: XCTestCase {
|
final class TravelAlbumModelsTests: XCTestCase {
|
||||||
|
func testPreviewProjectMapsMaterialToOriginalAsset() {
|
||||||
|
let material = TravelAlbumMaterial(
|
||||||
|
id: 8,
|
||||||
|
fileName: "IMG_0008.JPG",
|
||||||
|
fileUrl: "https://cdn.example.com/original.jpg",
|
||||||
|
fileSize: 4096,
|
||||||
|
coverUrl: "https://cdn.example.com/cover.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
let project = TravelAlbumPreviewProject(material: material)
|
||||||
|
|
||||||
|
XCTAssertEqual(project.originalMaterialId, 8)
|
||||||
|
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original])
|
||||||
|
XCTAssertEqual(project.orderedAssets.first?.displayURL, material.fileUrl)
|
||||||
|
XCTAssertEqual(project.orderedAssets.first?.previewURL, material.coverUrl)
|
||||||
|
XCTAssertFalse(project.hasVariants)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewProjectMapsNonemptyAIResultURLsInCanonicalOrder() {
|
||||||
|
let material = TravelAlbumMaterial(
|
||||||
|
id: 8,
|
||||||
|
fileName: "IMG_0008.JPG",
|
||||||
|
fileUrl: "https://cdn.example.com/original.jpg",
|
||||||
|
fileSize: 4096,
|
||||||
|
coverUrl: "https://cdn.example.com/cover.jpg",
|
||||||
|
aiRetouchBatchId: 55,
|
||||||
|
aiRefinedURL: " https://cdn.example.com/refined.jpg ",
|
||||||
|
aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
let project = TravelAlbumPreviewProject(material: material)
|
||||||
|
|
||||||
|
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched, .atmosphere])
|
||||||
|
XCTAssertEqual(
|
||||||
|
project.asset(for: .retouched)?.displayURL,
|
||||||
|
"https://cdn.example.com/refined.jpg"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
project.asset(for: .atmosphere)?.previewURL,
|
||||||
|
"https://cdn.example.com/atmosphere.jpg"
|
||||||
|
)
|
||||||
|
XCTAssertNil(project.asset(for: .cover))
|
||||||
|
XCTAssertTrue(project.hasVariants)
|
||||||
|
XCTAssertEqual(project.aiRetouchBatchId, 55)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewProjectIgnoresBlankAIResultURLs() {
|
||||||
|
let material = TravelAlbumMaterial(
|
||||||
|
id: 8,
|
||||||
|
fileUrl: "https://cdn.example.com/original.jpg",
|
||||||
|
aiRefinedURL: " \n ",
|
||||||
|
aiAtmosphereURL: "\t"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumPreviewProject(material: material).orderedAssets.map(\.kind),
|
||||||
|
[.original]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
|
||||||
|
let missingOriginal = TravelAlbumPreviewAsset(
|
||||||
|
id: "cover-only",
|
||||||
|
kind: .cover,
|
||||||
|
fileURL: " ",
|
||||||
|
coverURL: "https://cdn.example.com/cover.jpg",
|
||||||
|
fileName: "cover.jpg",
|
||||||
|
fileSize: 100
|
||||||
|
)
|
||||||
|
let missingCover = TravelAlbumPreviewAsset(
|
||||||
|
id: "original-only",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: "https://cdn.example.com/original.jpg",
|
||||||
|
coverURL: "\n",
|
||||||
|
fileName: "original.jpg",
|
||||||
|
fileSize: 200
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(missingOriginal.displayURL, missingOriginal.coverURL)
|
||||||
|
XCTAssertEqual(missingOriginal.previewURL, missingOriginal.coverURL)
|
||||||
|
XCTAssertEqual(missingCover.displayURL, missingCover.fileURL)
|
||||||
|
XCTAssertEqual(missingCover.previewURL, missingCover.fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewProjectKeepsCanonicalExistingVariantOrder() {
|
||||||
|
let project = makePreviewProject(kinds: [.cover, .original, .atmosphere])
|
||||||
|
|
||||||
|
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .atmosphere, .cover])
|
||||||
|
XCTAssertTrue(project.hasVariants)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewNavigatorBuildsNodesForBothModes() {
|
||||||
|
let projects = [
|
||||||
|
makePreviewProject(id: 1, kinds: [.original, .retouched]),
|
||||||
|
makePreviewProject(id: 2, kinds: [.original, .cover]),
|
||||||
|
]
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .projectsOnly),
|
||||||
|
[
|
||||||
|
TravelAlbumPreviewNode(projectIndex: 0, kind: .original),
|
||||||
|
TravelAlbumPreviewNode(projectIndex: 1, kind: .original),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .includeVariants).map(\.kind),
|
||||||
|
[.original, .retouched, .original, .cover]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewNavigatorBackwardFromOriginalTargetsPreviousOriginal() {
|
||||||
|
let projects = [
|
||||||
|
makePreviewProject(id: 1, kinds: [.original, .retouched, .cover]),
|
||||||
|
makePreviewProject(id: 2, kinds: [.original, .atmosphere]),
|
||||||
|
]
|
||||||
|
let nodes = TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .includeVariants)
|
||||||
|
let secondOriginal = nodes.firstIndex {
|
||||||
|
$0.projectIndex == 1 && $0.kind == .original
|
||||||
|
}!
|
||||||
|
|
||||||
|
let target = TravelAlbumPreviewNavigator.backwardTargetIndex(nodes: nodes, currentIndex: secondOriginal)
|
||||||
|
|
||||||
|
XCTAssertEqual(nodes[target], TravelAlbumPreviewNode(projectIndex: 0, kind: .original))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewDeletionPrefersNextThenFallsBackToPreviousAndClosesWhenEmpty() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||||
|
deletedProjectIndex: 0,
|
||||||
|
remainingProjectCount: 2
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||||
|
deletedProjectIndex: 1,
|
||||||
|
remainingProjectCount: 2
|
||||||
|
),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||||
|
deletedProjectIndex: 2,
|
||||||
|
remainingProjectCount: 2
|
||||||
|
),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
XCTAssertNil(
|
||||||
|
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||||
|
deletedProjectIndex: 0,
|
||||||
|
remainingProjectCount: 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() {
|
||||||
|
let originalOnly = makePreviewProject(id: 9, kinds: [.original])
|
||||||
|
let variants = TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: 10,
|
||||||
|
aiRetouchBatchId: 88,
|
||||||
|
assets: makePreviewProject(id: 10, kinds: [.original, .retouched, .atmosphere]).assets
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
originalOnly.aiRetouchWorkflow(albumId: 7, selectedKind: .original),
|
||||||
|
.initial(albumId: 7, materialIds: [9])
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .original),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .refined)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .atmosphere)
|
||||||
|
)
|
||||||
|
XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPlaceholderPreviewDeleteReturnsUnavailableWithoutMutation() async {
|
||||||
|
let handler = PlaceholderTravelAlbumPreviewActionHandler()
|
||||||
|
let deleteResult = await handler.deleteProject(originalMaterialId: 9)
|
||||||
|
XCTAssertEqual(
|
||||||
|
deleteResult,
|
||||||
|
.unavailable("项目删除接口待接入")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func testTravelAlbumDecodesSnakeCaseFields() throws {
|
func testTravelAlbumDecodesSnakeCaseFields() throws {
|
||||||
let json = """
|
let json = """
|
||||||
{
|
{
|
||||||
@@ -50,6 +241,11 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
"file_size": 2048,
|
"file_size": 2048,
|
||||||
"cover_url": "",
|
"cover_url": "",
|
||||||
"is_purchased": false,
|
"is_purchased": false,
|
||||||
|
"ai_retouch_status": 3,
|
||||||
|
"ai_retouch_status_name": "AI已修",
|
||||||
|
"ai_retouch_batch_id": 66,
|
||||||
|
"ai_refined_url": "https://cdn/refined.jpg",
|
||||||
|
"ai_atmosphere_url": "https://cdn/atmosphere.jpg",
|
||||||
"created_at": "",
|
"created_at": "",
|
||||||
"updated_at": ""
|
"updated_at": ""
|
||||||
}
|
}
|
||||||
@@ -59,6 +255,73 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(material.userEquityTravelId, 7)
|
XCTAssertEqual(material.userEquityTravelId, 7)
|
||||||
XCTAssertEqual(material.fileName, "IMG_0001.JPG")
|
XCTAssertEqual(material.fileName, "IMG_0001.JPG")
|
||||||
XCTAssertFalse(material.isPurchased)
|
XCTAssertFalse(material.isPurchased)
|
||||||
|
XCTAssertEqual(material.aiRetouchStatus, 3)
|
||||||
|
XCTAssertEqual(material.aiRetouchStatusName, "AI已修")
|
||||||
|
XCTAssertEqual(material.aiRetouchBatchId, 66)
|
||||||
|
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
|
||||||
|
XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTravelAlbumMaterialDefaultsInvalidAIFields() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"user_equity_travel_id": 7,
|
||||||
|
"status": 1,
|
||||||
|
"order_number": "",
|
||||||
|
"user_id": 9,
|
||||||
|
"file_name": "IMG_0001.JPG",
|
||||||
|
"file_type": 2,
|
||||||
|
"file_url": "https://cdn/a.jpg",
|
||||||
|
"file_size": 2048,
|
||||||
|
"cover_url": "",
|
||||||
|
"is_purchased": false,
|
||||||
|
"ai_retouch_status": "invalid",
|
||||||
|
"ai_retouch_status_name": null,
|
||||||
|
"ai_retouch_batch_id": "invalid",
|
||||||
|
"ai_refined_url": 123,
|
||||||
|
"created_at": "",
|
||||||
|
"updated_at": ""
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json)
|
||||||
|
|
||||||
|
XCTAssertEqual(material.aiRetouchStatus, 0)
|
||||||
|
XCTAssertEqual(material.aiRetouchStatusName, "")
|
||||||
|
XCTAssertEqual(material.aiRetouchBatchId, 0)
|
||||||
|
XCTAssertEqual(material.aiRefinedURL, "")
|
||||||
|
XCTAssertEqual(material.aiAtmosphereURL, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTravelAlbumMaterialBadgePriorityAndFallbacks() {
|
||||||
|
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传")
|
||||||
|
.badgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
||||||
|
)
|
||||||
|
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDisplayFormatters() {
|
func testDisplayFormatters() {
|
||||||
@@ -68,6 +331,25 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2 * 1024 * 1024), "2.0MB")
|
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2 * 1024 * 1024), "2.0MB")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makePreviewProject(
|
||||||
|
id: Int = 1,
|
||||||
|
kinds: [TravelAlbumPreviewAssetKind]
|
||||||
|
) -> TravelAlbumPreviewProject {
|
||||||
|
TravelAlbumPreviewProject(
|
||||||
|
originalMaterialId: id,
|
||||||
|
assets: kinds.map { kind in
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "\(id)-\(kind.rawValue)",
|
||||||
|
kind: kind,
|
||||||
|
fileURL: "https://cdn.example.com/\(id)-\(kind.rawValue).jpg",
|
||||||
|
coverURL: "",
|
||||||
|
fileName: "\(kind.title).jpg",
|
||||||
|
fileSize: 1024
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testBuildTodayAlbumNameUsesExistingSameDayCount() {
|
func testBuildTodayAlbumNameUsesExistingSameDayCount() {
|
||||||
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
|
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
api.infoResponse = TravelAlbum(id: 2, name: "详情")
|
api.infoResponse = TravelAlbum(id: 2, name: "详情")
|
||||||
api.materialListResponses = [
|
api.materialListResponses = [
|
||||||
TravelAlbumListResponse(total: 2, list: []),
|
TravelAlbumListResponse(total: 2, list: []),
|
||||||
|
TravelAlbumListResponse(total: 1, list: []),
|
||||||
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
|
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
|
||||||
]
|
]
|
||||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
@@ -88,7 +89,28 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(viewModel.album?.name, "详情")
|
XCTAssertEqual(viewModel.album?.name, "详情")
|
||||||
XCTAssertEqual(viewModel.allPhotoCount, 2)
|
XCTAssertEqual(viewModel.allPhotoCount, 2)
|
||||||
|
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
|
||||||
XCTAssertEqual(viewModel.materials.count, 2)
|
XCTAssertEqual(viewModel.materials.count, 2)
|
||||||
|
XCTAssertEqual(api.materialRequests.map(\.pageSize), [1, 1, 30])
|
||||||
|
XCTAssertEqual(api.materialRequests.map(\.isPurchased), [nil, 1, nil])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCountFailuresDoNotBlockMaterialList() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.infoResponse = TravelAlbum(id: 2, name: "详情")
|
||||||
|
api.materialListFailingCallIndexes = [0, 1]
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(total: 3, list: [TravelAlbumMaterial(id: 7)]),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
|
||||||
|
await viewModel.refreshAll(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.materialRequests.count, 3)
|
||||||
|
XCTAssertEqual(viewModel.materials.map(\.id), [7])
|
||||||
|
XCTAssertEqual(viewModel.allPhotoCount, 3)
|
||||||
|
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
|
||||||
|
XCTAssertFalse(viewModel.isLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTabAndSortTriggerMaterialRequests() async {
|
func testTabAndSortTriggerMaterialRequests() async {
|
||||||
@@ -106,6 +128,41 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
|
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSortOptionsProvideCompactCurrentStateTitles() {
|
||||||
|
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.createdAsc.compactTitle, "时间 ↑")
|
||||||
|
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.createdDesc.compactTitle, "时间 ↓")
|
||||||
|
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.fileNameAsc.compactTitle, "名称 ↑")
|
||||||
|
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.fileNameDesc.compactTitle, "名称 ↓")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAlbumCoverFallsBackToFirstMaterial() {
|
||||||
|
let album = TravelAlbum(id: 2, coverUrl: "")
|
||||||
|
let coverMaterial = TravelAlbumMaterial(id: 1, fileUrl: "original", coverUrl: "https://cdn.example.com/cover.jpg")
|
||||||
|
let originalMaterial = TravelAlbumMaterial(id: 2, fileUrl: "https://cdn.example.com/original.jpg", coverUrl: "")
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: [coverMaterial]),
|
||||||
|
"https://cdn.example.com/cover.jpg"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: [originalMaterial]),
|
||||||
|
"https://cdn.example.com/original.jpg"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumDisplayFormatter.albumCoverURL(
|
||||||
|
album: TravelAlbum(id: 2, coverUrl: "https://cdn.example.com/album.jpg"),
|
||||||
|
materials: [coverMaterial]
|
||||||
|
),
|
||||||
|
"https://cdn.example.com/album.jpg"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreationTimeFormatting() {
|
||||||
|
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText("2026-08-03 14:05:22"), "2026/08/03 14:05")
|
||||||
|
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText("2026-08-03T14:05:22+08:00"), "2026/08/03 14:05")
|
||||||
|
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
|
||||||
|
}
|
||||||
|
|
||||||
func testOnlyUnpurchasedMaterialCanBeSelected() {
|
func testOnlyUnpurchasedMaterialCanBeSelected() {
|
||||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
viewModel.toggleSelectionMode()
|
viewModel.toggleSelectionMode()
|
||||||
@@ -116,6 +173,74 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
|
||||||
|
|
||||||
|
viewModel.completeAIRetouchSubmission()
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.isSelectionMode)
|
||||||
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 3,
|
||||||
|
list: [
|
||||||
|
TravelAlbumMaterial(id: 1),
|
||||||
|
TravelAlbumMaterial(id: 2, isPurchased: true),
|
||||||
|
TravelAlbumMaterial(id: 3),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
await viewModel.loadMaterials(reset: true, api: api)
|
||||||
|
|
||||||
|
viewModel.removeMaterialAfterPreviewDeletion(id: 2)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.materials.map(\.id), [1, 3])
|
||||||
|
XCTAssertEqual(viewModel.allPhotoCount, 2)
|
||||||
|
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreviewReloadRefetchesAllLoadedPagesAndReplacesMaterialsById() async throws {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 35,
|
||||||
|
list: (1 ... 30).map { TravelAlbumMaterial(id: $0) }
|
||||||
|
),
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 35,
|
||||||
|
list: (31 ... 35).map { TravelAlbumMaterial(id: $0) }
|
||||||
|
),
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 35,
|
||||||
|
list: (1 ... 30).map {
|
||||||
|
TravelAlbumMaterial(id: $0, aiRetouchBatchId: $0 == 7 ? 700 : 0)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 35,
|
||||||
|
list: (31 ... 35).map { TravelAlbumMaterial(id: $0) }
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
await viewModel.loadMaterials(reset: true, api: api)
|
||||||
|
await viewModel.loadMaterials(reset: false, api: api)
|
||||||
|
|
||||||
|
let response = try await viewModel.reloadLoadedMaterials(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.total, 35)
|
||||||
|
XCTAssertEqual(response.list.count, 35)
|
||||||
|
XCTAssertEqual(response.list.first { $0.id == 7 }?.aiRetouchBatchId, 700)
|
||||||
|
XCTAssertEqual(api.materialRequests.suffix(2).map(\.page), [1, 2])
|
||||||
|
XCTAssertEqual(api.materialRequests.suffix(2).map(\.pageSize), [30, 30])
|
||||||
|
}
|
||||||
|
|
||||||
func testDeleteAlbumCallsCallback() async {
|
func testDeleteAlbumCallsCallback() async {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
@@ -954,10 +1079,19 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
var createResponse = TravelAlbumCreateResponse(id: 0)
|
var createResponse = TravelAlbumCreateResponse(id: 0)
|
||||||
var infoResponse = TravelAlbum()
|
var infoResponse = TravelAlbum()
|
||||||
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
||||||
|
var materialListFailingCallIndexes: Set<Int> = []
|
||||||
var uploadMaterialResponse = TravelAlbumMaterial()
|
var uploadMaterialResponse = TravelAlbumMaterial()
|
||||||
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
|
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
|
||||||
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
||||||
|
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
|
||||||
var createError: Error?
|
var createError: Error?
|
||||||
|
var aiRetouchTemplatesError: Error?
|
||||||
|
var submitAIRetouchError: Error?
|
||||||
|
var submitAIRetouchDelayNanoseconds: UInt64 = 0
|
||||||
|
var submitAIReretouchError: Error?
|
||||||
|
var submitAIReretouchDelayNanoseconds: UInt64 = 0
|
||||||
|
var deleteMaterialError: Error?
|
||||||
|
var deleteMaterialDelayNanoseconds: UInt64 = 0
|
||||||
|
|
||||||
private(set) var availableOrdersCallCount = 0
|
private(set) var availableOrdersCallCount = 0
|
||||||
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
||||||
@@ -966,6 +1100,9 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
private(set) var materialClientPhotoIdsCallCount = 0
|
private(set) var materialClientPhotoIdsCallCount = 0
|
||||||
private(set) var deletedAlbumIds: [Int] = []
|
private(set) var deletedAlbumIds: [Int] = []
|
||||||
private(set) var deletedMaterialIds: [Int] = []
|
private(set) var deletedMaterialIds: [Int] = []
|
||||||
|
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
||||||
|
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
||||||
|
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
|
||||||
|
|
||||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||||
availableOrdersCallCount += 1
|
availableOrdersCallCount += 1
|
||||||
@@ -993,6 +1130,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
orderBy: Int,
|
orderBy: Int,
|
||||||
isPurchased: Int?
|
isPurchased: Int?
|
||||||
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
|
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
|
||||||
|
let callIndex = materialRequests.count
|
||||||
materialRequests.append(
|
materialRequests.append(
|
||||||
MaterialRequest(
|
MaterialRequest(
|
||||||
userEquityTravelId: userEquityTravelId,
|
userEquityTravelId: userEquityTravelId,
|
||||||
@@ -1002,6 +1140,9 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
isPurchased: isPurchased
|
isPurchased: isPurchased
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if materialListFailingCallIndexes.contains(callIndex) {
|
||||||
|
throw APIError.serverCode(500, "素材数量加载失败")
|
||||||
|
}
|
||||||
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
|
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
|
||||||
return materialListResponses.removeFirst()
|
return materialListResponses.removeFirst()
|
||||||
}
|
}
|
||||||
@@ -1022,9 +1163,35 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
|
|
||||||
func deleteMaterial(id: Int) async throws {
|
func deleteMaterial(id: Int) async throws {
|
||||||
deletedMaterialIds.append(id)
|
deletedMaterialIds.append(id)
|
||||||
|
if deleteMaterialDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
|
||||||
|
}
|
||||||
|
if let deleteMaterialError { throw deleteMaterialError }
|
||||||
}
|
}
|
||||||
|
|
||||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
||||||
mpCodeResponse
|
mpCodeResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
|
||||||
|
aiRetouchTemplateScenicIds.append(scenicId)
|
||||||
|
if let aiRetouchTemplatesError { throw aiRetouchTemplatesError }
|
||||||
|
return aiRetouchTemplatesResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
||||||
|
aiRetouchRequests.append(request)
|
||||||
|
if submitAIRetouchDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
|
||||||
|
}
|
||||||
|
if let submitAIRetouchError { throw submitAIRetouchError }
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
|
||||||
|
aiReretouchRequests.append(request)
|
||||||
|
if submitAIReretouchDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: submitAIReretouchDelayNanoseconds)
|
||||||
|
}
|
||||||
|
if let submitAIReretouchError { throw submitAIReretouchError }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||