feat: add travel album module
This commit is contained in:
@ -21,6 +21,7 @@ final class NetworkServices {
|
|||||||
let inviteAPI: InviteAPI
|
let inviteAPI: InviteAPI
|
||||||
let walletAPI: WalletAPI
|
let walletAPI: WalletAPI
|
||||||
let scenicQueueAPI: ScenicQueueAPI
|
let scenicQueueAPI: ScenicQueueAPI
|
||||||
|
let travelAlbumAPI: TravelAlbumAPI
|
||||||
let uploadAPI: UploadAPI
|
let uploadAPI: UploadAPI
|
||||||
let ossUploadService: OSSUploadService
|
let ossUploadService: OSSUploadService
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ final class NetworkServices {
|
|||||||
inviteAPI = InviteAPI(client: client)
|
inviteAPI = InviteAPI(client: client)
|
||||||
walletAPI = WalletAPI(client: client)
|
walletAPI = WalletAPI(client: client)
|
||||||
scenicQueueAPI = ScenicQueueAPI(client: client)
|
scenicQueueAPI = ScenicQueueAPI(client: client)
|
||||||
|
travelAlbumAPI = TravelAlbumAPI(client: client)
|
||||||
uploadAPI = UploadAPI(client: client)
|
uploadAPI = UploadAPI(client: client)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
client.bindAuthTokenProvider {
|
client.bindAuthTokenProvider {
|
||||||
@ -58,6 +60,7 @@ final class NetworkServices {
|
|||||||
inviteAPI = InviteAPI(client: apiClient)
|
inviteAPI = InviteAPI(client: apiClient)
|
||||||
walletAPI = WalletAPI(client: apiClient)
|
walletAPI = WalletAPI(client: apiClient)
|
||||||
scenicQueueAPI = ScenicQueueAPI(client: apiClient)
|
scenicQueueAPI = ScenicQueueAPI(client: apiClient)
|
||||||
|
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
|
||||||
uploadAPI = UploadAPI(client: apiClient)
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ enum HomeMenuCatalog {
|
|||||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"),
|
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"),
|
||||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"),
|
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"),
|
||||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"),
|
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"),
|
||||||
|
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),
|
||||||
HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "photo.on.rectangle.angled"),
|
HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "photo.on.rectangle.angled"),
|
||||||
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"),
|
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"),
|
||||||
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),
|
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),
|
||||||
|
|||||||
@ -58,6 +58,8 @@ enum HomeRouteHandler {
|
|||||||
viewController.navigationController?.pushViewController(ScenicQueueViewController(), animated: true)
|
viewController.navigationController?.pushViewController(ScenicQueueViewController(), animated: true)
|
||||||
case "task_management", "task_management_editor":
|
case "task_management", "task_management_editor":
|
||||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||||
|
case "travel_album":
|
||||||
|
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
|
||||||
case "wallet":
|
case "wallet":
|
||||||
showDeveloping(from: viewController)
|
showDeveloping(from: viewController)
|
||||||
default:
|
default:
|
||||||
|
|||||||
139
suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift
Normal file
139
suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumAPI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 旅拍相册服务协议,抽象相册列表、创建、详情与素材接口。
|
||||||
|
protocol TravelAlbumServing {
|
||||||
|
/// 拉取可绑定订单。
|
||||||
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder]
|
||||||
|
|
||||||
|
/// 创建旅拍相册。
|
||||||
|
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse
|
||||||
|
|
||||||
|
/// 拉取旅拍相册列表。
|
||||||
|
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum>
|
||||||
|
|
||||||
|
/// 拉取旅拍相册详情。
|
||||||
|
func info(id: Int) async throws -> TravelAlbum
|
||||||
|
|
||||||
|
/// 拉取旅拍相册素材列表。
|
||||||
|
func materialList(
|
||||||
|
userEquityTravelId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderBy: Int,
|
||||||
|
isPurchased: Int?
|
||||||
|
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
|
||||||
|
|
||||||
|
/// 删除旅拍相册。
|
||||||
|
func deleteAlbum(id: Int) async throws
|
||||||
|
|
||||||
|
/// 删除旅拍相册素材。
|
||||||
|
func deleteMaterial(id: Int) async throws
|
||||||
|
|
||||||
|
/// 拉取相册小程序码。
|
||||||
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 旅拍相册 API,封装 Android `photog/travel-album` 接口组。
|
||||||
|
final class TravelAlbumAPI: TravelAlbumServing {
|
||||||
|
private let client: APIClient
|
||||||
|
private let basePath = "/api/yf-handset-app/photog/travel-album"
|
||||||
|
|
||||||
|
/// 初始化旅拍相册 API。
|
||||||
|
init(client: APIClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取可绑定订单。
|
||||||
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||||
|
try await client.send(APIRequest(method: .get, path: "\(basePath)/available-order"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建旅拍相册。
|
||||||
|
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/create", body: request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取旅拍相册列表。
|
||||||
|
func list(page: Int = 1, pageSize: Int = 20) async throws -> TravelAlbumListResponse<TravelAlbum> {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/list",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||||
|
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取旅拍相册详情。
|
||||||
|
func info(id: Int) async throws -> TravelAlbum {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/info",
|
||||||
|
queryItems: [URLQueryItem(name: "id", value: String(id))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取旅拍相册素材列表。
|
||||||
|
func materialList(
|
||||||
|
userEquityTravelId: Int,
|
||||||
|
page: Int = 1,
|
||||||
|
pageSize: Int = 20,
|
||||||
|
orderBy: Int = 2,
|
||||||
|
isPurchased: Int? = nil
|
||||||
|
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
|
||||||
|
var items = [
|
||||||
|
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
|
||||||
|
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||||
|
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||||
|
URLQueryItem(name: "order_by", value: String(orderBy)),
|
||||||
|
]
|
||||||
|
if let isPurchased {
|
||||||
|
items.append(URLQueryItem(name: "is_purchased", value: String(isPurchased)))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "\(basePath)/material-list", queryItems: items)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除旅拍相册。
|
||||||
|
func deleteAlbum(id: Int) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "\(basePath)/delete", body: TravelAlbumIDRequest(id: id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除旅拍相册素材。
|
||||||
|
func deleteMaterial(id: Int) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取相册小程序码。
|
||||||
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/mp-code",
|
||||||
|
queryItems: [URLQueryItem(name: "id", value: String(id))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册 ID 请求体。
|
||||||
|
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
||||||
|
let id: Int
|
||||||
|
}
|
||||||
256
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
256
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。
|
||||||
|
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let phone: String
|
||||||
|
|
||||||
|
init(id: Int = 0, phone: String = "") {
|
||||||
|
self.id = id
|
||||||
|
self.phone = phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册实体,对齐 Android `TravelAlbumEntity`。
|
||||||
|
struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let storeUserId: Int
|
||||||
|
let name: String
|
||||||
|
let type: Int
|
||||||
|
let orderNumber: String
|
||||||
|
let materialNum: Int
|
||||||
|
let materialPrice: Int
|
||||||
|
let materialPackagePrice: Int
|
||||||
|
let photoPrice: Int
|
||||||
|
let coverUrl: String
|
||||||
|
let userId: Int
|
||||||
|
let status: Int
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
let user: TravelAlbumUser?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case storeUserId = "store_user_id"
|
||||||
|
case name
|
||||||
|
case type
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case materialNum = "material_num"
|
||||||
|
case materialPrice = "material_price"
|
||||||
|
case materialPackagePrice = "material_package_price"
|
||||||
|
case photoPrice = "photo_price"
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
case userId = "user_id"
|
||||||
|
case status
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
case user
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: Int = 0,
|
||||||
|
storeUserId: Int = 0,
|
||||||
|
name: String = "",
|
||||||
|
type: Int = 0,
|
||||||
|
orderNumber: String = "",
|
||||||
|
materialNum: Int = 0,
|
||||||
|
materialPrice: Int = 0,
|
||||||
|
materialPackagePrice: Int = 0,
|
||||||
|
photoPrice: Int = 0,
|
||||||
|
coverUrl: String = "",
|
||||||
|
userId: Int = 0,
|
||||||
|
status: Int = 0,
|
||||||
|
createdAt: String = "",
|
||||||
|
updatedAt: String = "",
|
||||||
|
user: TravelAlbumUser? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.storeUserId = storeUserId
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.materialNum = materialNum
|
||||||
|
self.materialPrice = materialPrice
|
||||||
|
self.materialPackagePrice = materialPackagePrice
|
||||||
|
self.photoPrice = photoPrice
|
||||||
|
self.coverUrl = coverUrl
|
||||||
|
self.userId = userId
|
||||||
|
self.status = status
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
self.user = user
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示用手机号。
|
||||||
|
var displayPhone: String {
|
||||||
|
user?.phone ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册可绑定订单,对齐 Android `TravelAlbumAvailableOrderEntity`。
|
||||||
|
struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let projectName: String
|
||||||
|
let orderNumber: String
|
||||||
|
let userPhone: String
|
||||||
|
let userId: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case projectName = "project_name"
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case userPhone = "user_phone"
|
||||||
|
case userId = "user_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(projectName: String = "", orderNumber: String = "", userPhone: String = "", userId: Int = 0) {
|
||||||
|
self.projectName = projectName
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.userPhone = userPhone
|
||||||
|
self.userId = userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
|
||||||
|
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let status: Int
|
||||||
|
let orderNumber: String
|
||||||
|
let userId: Int
|
||||||
|
let fileName: String
|
||||||
|
let fileType: Int
|
||||||
|
let fileUrl: String
|
||||||
|
let fileSize: Int
|
||||||
|
let coverUrl: String
|
||||||
|
let isPurchased: Bool
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case status
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case userId = "user_id"
|
||||||
|
case fileName = "file_name"
|
||||||
|
case fileType = "file_type"
|
||||||
|
case fileUrl = "file_url"
|
||||||
|
case fileSize = "file_size"
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
case isPurchased = "is_purchased"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: Int = 0,
|
||||||
|
userEquityTravelId: Int = 0,
|
||||||
|
status: Int = 0,
|
||||||
|
orderNumber: String = "",
|
||||||
|
userId: Int = 0,
|
||||||
|
fileName: String = "",
|
||||||
|
fileType: Int = 0,
|
||||||
|
fileUrl: String = "",
|
||||||
|
fileSize: Int = 0,
|
||||||
|
coverUrl: String = "",
|
||||||
|
isPurchased: Bool = false,
|
||||||
|
createdAt: String = "",
|
||||||
|
updatedAt: String = ""
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.userEquityTravelId = userEquityTravelId
|
||||||
|
self.status = status
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.userId = userId
|
||||||
|
self.fileName = fileName
|
||||||
|
self.fileType = fileType
|
||||||
|
self.fileUrl = fileUrl
|
||||||
|
self.fileSize = fileSize
|
||||||
|
self.coverUrl = coverUrl
|
||||||
|
self.isPurchased = isPurchased
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
|
||||||
|
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let type: Int
|
||||||
|
let orderNumber: String?
|
||||||
|
let materialNum: Int?
|
||||||
|
let materialPrice: Double?
|
||||||
|
let materialPackagePrice: Double?
|
||||||
|
let photoPrice: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case type
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case materialNum = "material_num"
|
||||||
|
case materialPrice = "material_price"
|
||||||
|
case materialPackagePrice = "material_package_price"
|
||||||
|
case photoPrice = "photo_price"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册通用分页响应。
|
||||||
|
struct TravelAlbumListResponse<Item: Decodable & Sendable & Equatable>: Decodable, Sendable, Equatable {
|
||||||
|
let total: Int
|
||||||
|
let list: [Item]
|
||||||
|
|
||||||
|
init(total: Int = 0, list: [Item] = []) {
|
||||||
|
self.total = total
|
||||||
|
self.list = list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册创建响应。
|
||||||
|
struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册小程序码响应。
|
||||||
|
struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
|
||||||
|
let mpCodeOssUrl: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case mpCodeOssUrl = "mp_code_oss_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册展示格式化工具。
|
||||||
|
enum TravelAlbumDisplayFormatter {
|
||||||
|
/// 脱敏手机号。
|
||||||
|
static func maskPhone(_ phone: String) -> String {
|
||||||
|
guard phone.count >= 7 else { return phone.isEmpty ? "--" : phone }
|
||||||
|
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成文件大小文本。
|
||||||
|
static func fileSizeText(_ bytes: Int) -> String {
|
||||||
|
let value = Double(max(bytes, 0))
|
||||||
|
if value >= 1024 * 1024 * 1024 {
|
||||||
|
return String(format: "%.1fGB", value / 1024 / 1024 / 1024)
|
||||||
|
}
|
||||||
|
if value >= 1024 * 1024 {
|
||||||
|
return String(format: "%.1fMB", value / 1024 / 1024)
|
||||||
|
}
|
||||||
|
if value >= 1024 {
|
||||||
|
return String(format: "%.1fKB", value / 1024)
|
||||||
|
}
|
||||||
|
return "\(Int(value))B"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建时间短展示。
|
||||||
|
static func shortTaskTime(_ text: String) -> String {
|
||||||
|
if text.count >= 16 {
|
||||||
|
return String(text.prefix(16))
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,231 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumDetailViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 旅拍相册详情 ViewModel,负责相册信息、素材分页筛选和删除状态。
|
||||||
|
final class TravelAlbumDetailViewModel {
|
||||||
|
enum Tab: Int, Sendable {
|
||||||
|
case all = 0
|
||||||
|
case purchased = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 排序方式。
|
||||||
|
enum SortOption: Int, CaseIterable, Sendable {
|
||||||
|
case createdAsc = 1
|
||||||
|
case createdDesc = 2
|
||||||
|
case fileNameAsc = 3
|
||||||
|
case fileNameDesc = 4
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .createdAsc: "创建时间正序"
|
||||||
|
case .createdDesc: "创建时间倒序"
|
||||||
|
case .fileNameAsc: "文件名正序"
|
||||||
|
case .fileNameDesc: "文件名倒序"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var album: TravelAlbum?
|
||||||
|
private(set) var materials: [TravelAlbumMaterial] = []
|
||||||
|
private(set) var allPhotoCount = 0
|
||||||
|
private(set) var selectedTab: Tab = .all
|
||||||
|
private(set) var sortOption: SortOption = .createdDesc
|
||||||
|
private(set) var isLoading = true
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var isSelectionMode = false
|
||||||
|
private(set) var selectedMaterialIds: Set<Int> = []
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onDeletedAlbum: ((Int) -> Void)?
|
||||||
|
|
||||||
|
let albumId: Int
|
||||||
|
private var currentPage = 1
|
||||||
|
private var canLoadMore = false
|
||||||
|
private var isLoadingMore = false
|
||||||
|
private let pageSize = 30
|
||||||
|
|
||||||
|
init(albumId: Int) {
|
||||||
|
self.albumId = albumId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载详情与素材。
|
||||||
|
func refreshAll(api: any TravelAlbumServing) async {
|
||||||
|
guard albumId > 0 else {
|
||||||
|
isLoading = false
|
||||||
|
onShowMessage?("相册不存在")
|
||||||
|
notifyStateChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isRefreshing = true
|
||||||
|
notifyStateChange()
|
||||||
|
await loadAlbumInfo(api: api)
|
||||||
|
await loadAllPhotoCount(api: api)
|
||||||
|
await loadMaterials(reset: true, api: api)
|
||||||
|
isRefreshing = false
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取相册详情。
|
||||||
|
func loadAlbumInfo(api: any TravelAlbumServing) async {
|
||||||
|
do {
|
||||||
|
album = try await api.info(id: albumId)
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取全部照片数量。
|
||||||
|
func loadAllPhotoCount(api: any TravelAlbumServing) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.materialList(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
orderBy: sortOption.rawValue,
|
||||||
|
isPurchased: nil
|
||||||
|
)
|
||||||
|
allPhotoCount = response.total
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换素材 tab。
|
||||||
|
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||||
|
guard selectedTab != tab else { return }
|
||||||
|
selectedTab = tab
|
||||||
|
isSelectionMode = false
|
||||||
|
selectedMaterialIds = []
|
||||||
|
notifyStateChange()
|
||||||
|
await loadMaterials(reset: true, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换排序。
|
||||||
|
func setSortOption(_ option: SortOption, api: any TravelAlbumServing) async {
|
||||||
|
guard sortOption != option else { return }
|
||||||
|
sortOption = option
|
||||||
|
notifyStateChange()
|
||||||
|
await loadMaterials(reset: true, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取素材列表。
|
||||||
|
func loadMaterials(reset: Bool, api: any TravelAlbumServing) async {
|
||||||
|
guard albumId > 0 else { return }
|
||||||
|
if reset {
|
||||||
|
currentPage = 1
|
||||||
|
canLoadMore = false
|
||||||
|
} else {
|
||||||
|
guard canLoadMore, !isLoadingMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
currentPage += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.materialList(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderBy: sortOption.rawValue,
|
||||||
|
isPurchased: selectedTab == .purchased ? 1 : nil
|
||||||
|
)
|
||||||
|
materials = reset ? response.list : materials + response.list
|
||||||
|
canLoadMore = materials.count < response.total
|
||||||
|
if selectedTab == .all {
|
||||||
|
allPhotoCount = response.total
|
||||||
|
}
|
||||||
|
isLoadingMore = false
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
if !reset && currentPage > 1 {
|
||||||
|
currentPage -= 1
|
||||||
|
}
|
||||||
|
isLoadingMore = false
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据滚动位置触发加载更多。
|
||||||
|
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async {
|
||||||
|
if lastVisibleIndex >= materials.count - 6 {
|
||||||
|
await loadMaterials(reset: false, api: api)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换选择模式。
|
||||||
|
func toggleSelectionMode() {
|
||||||
|
guard selectedTab == .all else { return }
|
||||||
|
isSelectionMode.toggle()
|
||||||
|
if !isSelectionMode {
|
||||||
|
selectedMaterialIds = []
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换素材选中状态。
|
||||||
|
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||||
|
guard isSelectionMode else { return }
|
||||||
|
guard material.status == 1 else {
|
||||||
|
onShowMessage?("仅未购买素材可删除")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if selectedMaterialIds.contains(material.id) {
|
||||||
|
selectedMaterialIds.remove(material.id)
|
||||||
|
} else {
|
||||||
|
selectedMaterialIds.insert(material.id)
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除已选素材。
|
||||||
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
|
let ids = Array(selectedMaterialIds)
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
onShowMessage?("请选择要删除的素材")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
for id in ids {
|
||||||
|
try await api.deleteMaterial(id: id)
|
||||||
|
}
|
||||||
|
onShowMessage?("删除成功")
|
||||||
|
isSelectionMode = false
|
||||||
|
selectedMaterialIds = []
|
||||||
|
await refreshAll(api: api)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除当前相册。
|
||||||
|
func deleteAlbum(api: any TravelAlbumServing) async {
|
||||||
|
do {
|
||||||
|
try await api.deleteAlbum(id: albumId)
|
||||||
|
onShowMessage?("相册已删除")
|
||||||
|
onDeletedAlbum?(albumId)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumEntryViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 旅拍相册入口页 ViewModel,负责列表、创建相册和相册码状态。
|
||||||
|
final class TravelAlbumEntryViewModel {
|
||||||
|
enum CreateMode: Int, Sendable {
|
||||||
|
case preShoot = 0
|
||||||
|
case preOrder = 1
|
||||||
|
|
||||||
|
var apiType: Int {
|
||||||
|
switch self {
|
||||||
|
case .preShoot: 1
|
||||||
|
case .preOrder: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册码弹窗状态。
|
||||||
|
struct AlbumCodeState: Sendable, Equatable {
|
||||||
|
let albumId: Int
|
||||||
|
let albumName: String
|
||||||
|
var qrImageUrl: String
|
||||||
|
var isLoading: Bool
|
||||||
|
var isDownloading: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var albums: [TravelAlbum] = []
|
||||||
|
private(set) var albumTotal = 0
|
||||||
|
private(set) var availableOrders: [TravelAlbumAvailableOrder] = []
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isCreating = false
|
||||||
|
private(set) var isCreateSheetVisible = false
|
||||||
|
private(set) var albumCodeState: AlbumCodeState?
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onCreatedAlbum: ((TravelAlbumCreateResponse) -> Void)?
|
||||||
|
|
||||||
|
private let currentScenicIdProvider: () -> Int
|
||||||
|
private let dateProvider: () -> Date
|
||||||
|
|
||||||
|
init(
|
||||||
|
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId },
|
||||||
|
dateProvider: @escaping () -> Date = Date.init
|
||||||
|
) {
|
||||||
|
self.currentScenicIdProvider = currentScenicIdProvider
|
||||||
|
self.dateProvider = dateProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新拉取相册列表。
|
||||||
|
func loadAlbums(api: any TravelAlbumServing) async {
|
||||||
|
isLoading = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.list(page: 1, pageSize: 20)
|
||||||
|
albumTotal = response.total
|
||||||
|
albums = response.list
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
albums = []
|
||||||
|
albumTotal = 0
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开创建相册弹窗。
|
||||||
|
func openCreateSheet(api: any TravelAlbumServing) async {
|
||||||
|
guard !isCreating else { return }
|
||||||
|
guard currentScenicIdProvider() > 0 else {
|
||||||
|
onShowMessage?("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isCreateSheetVisible = true
|
||||||
|
notifyStateChange()
|
||||||
|
await loadAvailableOrders(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭创建相册弹窗。
|
||||||
|
func dismissCreateSheet() {
|
||||||
|
isCreateSheetVisible = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取可绑定订单。
|
||||||
|
func loadAvailableOrders(api: any TravelAlbumServing) async {
|
||||||
|
do {
|
||||||
|
availableOrders = try await api.availableOrders()
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
availableOrders = []
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验并创建相册任务。
|
||||||
|
func confirmCreate(
|
||||||
|
mode: CreateMode,
|
||||||
|
freeCount: String,
|
||||||
|
singlePrice: String,
|
||||||
|
packagePrice: String,
|
||||||
|
order: TravelAlbumAvailableOrder?,
|
||||||
|
api: any TravelAlbumServing
|
||||||
|
) async {
|
||||||
|
guard !isCreating else { return }
|
||||||
|
guard currentScenicIdProvider() > 0 else {
|
||||||
|
onShowMessage?("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == .preOrder && order == nil {
|
||||||
|
onShowMessage?("请选择绑定订单")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == .preShoot && singlePrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
onShowMessage?("请输入单张照片价格")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let materialPrice = Double(singlePrice)
|
||||||
|
if mode == .preShoot && materialPrice == nil {
|
||||||
|
onShowMessage?("请输入有效的单张照片价格")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let albumName: String
|
||||||
|
switch mode {
|
||||||
|
case .preOrder:
|
||||||
|
albumName = order?.projectName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? buildTodayAlbumName()
|
||||||
|
case .preShoot:
|
||||||
|
albumName = buildTodayAlbumName()
|
||||||
|
}
|
||||||
|
|
||||||
|
let request: TravelAlbumCreateRequest
|
||||||
|
switch mode {
|
||||||
|
case .preShoot:
|
||||||
|
request = TravelAlbumCreateRequest(
|
||||||
|
name: albumName,
|
||||||
|
type: mode.apiType,
|
||||||
|
orderNumber: nil,
|
||||||
|
materialNum: Int(freeCount) ?? 0,
|
||||||
|
materialPrice: materialPrice,
|
||||||
|
materialPackagePrice: Double(packagePrice) ?? 0,
|
||||||
|
photoPrice: 0
|
||||||
|
)
|
||||||
|
case .preOrder:
|
||||||
|
request = TravelAlbumCreateRequest(
|
||||||
|
name: albumName,
|
||||||
|
type: mode.apiType,
|
||||||
|
orderNumber: order?.orderNumber,
|
||||||
|
materialNum: nil,
|
||||||
|
materialPrice: nil,
|
||||||
|
materialPackagePrice: nil,
|
||||||
|
photoPrice: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreating = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isCreating = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.create(request)
|
||||||
|
isCreateSheetVisible = false
|
||||||
|
onShowMessage?("任务创建成功")
|
||||||
|
onCreatedAlbum?(response)
|
||||||
|
await loadAlbums(api: api)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription
|
||||||
|
if message.contains("订单已关联") {
|
||||||
|
onShowMessage?("该订单已关联旅拍相册,请刷新后重新选择")
|
||||||
|
await loadAvailableOrders(api: api)
|
||||||
|
} else {
|
||||||
|
onShowMessage?(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开相册码弹窗并加载二维码。
|
||||||
|
func openAlbumCode(album: TravelAlbum, api: any TravelAlbumServing) async {
|
||||||
|
albumCodeState = AlbumCodeState(
|
||||||
|
albumId: album.id,
|
||||||
|
albumName: album.name,
|
||||||
|
qrImageUrl: "",
|
||||||
|
isLoading: true,
|
||||||
|
isDownloading: false
|
||||||
|
)
|
||||||
|
notifyStateChange()
|
||||||
|
do {
|
||||||
|
let response = try await api.mpCode(id: album.id)
|
||||||
|
guard albumCodeState?.albumId == album.id else { return }
|
||||||
|
albumCodeState?.qrImageUrl = response.mpCodeOssUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
albumCodeState?.isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
guard albumCodeState?.albumId == album.id else { return }
|
||||||
|
albumCodeState?.isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "获取小程序码失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭相册码弹窗。
|
||||||
|
func dismissAlbumCode() {
|
||||||
|
albumCodeState = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置二维码下载状态。
|
||||||
|
func setDownloadingQRCode(_ isDownloading: Bool) {
|
||||||
|
albumCodeState?.isDownloading = isDownloading
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 Android 同款今日相册名。
|
||||||
|
func buildTodayAlbumName() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.calendar = Calendar(identifier: .gregorian)
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
let todayText = formatter.string(from: dateProvider())
|
||||||
|
let todayCount = albums.filter { $0.createdAt.hasPrefix(todayText) }.count
|
||||||
|
return "\(todayText)-\(String(format: "%03d", todayCount + 1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nonEmpty: String? {
|
||||||
|
isEmpty ? nil : self
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// WiredCameraTransferViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 有线相机传输页 ViewModel,仅承载断开空态 UI 文案,不接入 OTG 或图片存储业务。
|
||||||
|
final class WiredCameraTransferViewModel {
|
||||||
|
let albumId: Int
|
||||||
|
let albumTitle: String
|
||||||
|
let headerPhone: String
|
||||||
|
let scenicSpotLabel: String?
|
||||||
|
let cameraStatusText = "未连接 USB"
|
||||||
|
let actionButtonText = "等待连接"
|
||||||
|
let deviceModelName = "设备存储"
|
||||||
|
let availableStorageText = "-- GB 可用"
|
||||||
|
let retouchOption = "不修图"
|
||||||
|
let photoFormatOption = "JPG"
|
||||||
|
let transferModeOption = "边拍边传"
|
||||||
|
let tabCounts = [0, 0, 0]
|
||||||
|
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
init(albumId: Int, albumTitle: String, headerPhone: String, scenicSpotLabel: String? = nil) {
|
||||||
|
self.albumId = albumId
|
||||||
|
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
|
||||||
|
self.headerPhone = headerPhone
|
||||||
|
self.scenicSpotLabel = scenicSpotLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 响应所有传输按钮点击,仅提示 UI 已同步。
|
||||||
|
func showUIOnlyMessage() {
|
||||||
|
onShowMessage?("仅同步 UI,暂未接入 OTG 传输")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,393 @@
|
|||||||
|
//
|
||||||
|
// CreateTravelAlbumSheetViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 新建相册任务底部 Sheet,对齐 Android `CreateTravelAlbumModal`。
|
||||||
|
final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||||
|
var onDismissed: (() -> Void)?
|
||||||
|
|
||||||
|
private let viewModel: TravelAlbumEntryViewModel
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
private var mode: TravelAlbumEntryViewModel.CreateMode = .preShoot
|
||||||
|
private var selectedOrder: TravelAlbumAvailableOrder?
|
||||||
|
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let contentView = UIView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let preShootCard = TravelAlbumModeOptionView()
|
||||||
|
private let preOrderCard = TravelAlbumModeOptionView()
|
||||||
|
private let fieldsStack = UIStackView()
|
||||||
|
private let freeCountField = UITextField()
|
||||||
|
private let singlePriceField = UITextField()
|
||||||
|
private let packagePriceField = UITextField()
|
||||||
|
private let orderButton = UIButton(type: .system)
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.api = api
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .pageSheet
|
||||||
|
if let sheetPresentationController {
|
||||||
|
sheetPresentationController.detents = [.medium(), .large()]
|
||||||
|
sheetPresentationController.prefersGrabberVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = .white
|
||||||
|
titleLabel.text = "新建相册任务"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
preShootCard.apply(
|
||||||
|
title: "先拍再买",
|
||||||
|
desc: "拍完后分享给用户,用户在小程序上选择性购买",
|
||||||
|
selected: true
|
||||||
|
)
|
||||||
|
preOrderCard.apply(
|
||||||
|
title: "买了再拍",
|
||||||
|
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
|
||||||
|
selected: false
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsStack.axis = .vertical
|
||||||
|
fieldsStack.spacing = 14
|
||||||
|
|
||||||
|
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
|
||||||
|
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
|
||||||
|
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
|
||||||
|
|
||||||
|
orderButton.contentHorizontalAlignment = .left
|
||||||
|
orderButton.setTitle("请选择订单", for: .normal)
|
||||||
|
orderButton.setTitleColor(AppColor.textTertiary, for: .normal)
|
||||||
|
orderButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
orderButton.layer.cornerRadius = 8
|
||||||
|
orderButton.layer.borderColor = AppColor.border.cgColor
|
||||||
|
orderButton.layer.borderWidth = 1
|
||||||
|
orderButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||||||
|
|
||||||
|
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
|
||||||
|
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
||||||
|
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(contentView)
|
||||||
|
[titleLabel, preShootCard, preOrderCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview)
|
||||||
|
rebuildFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
scrollView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
contentView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
make.width.equalToSuperview()
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(18)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
}
|
||||||
|
preShootCard.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(18)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
preOrderCard.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(preShootCard.snp.bottom).offset(10)
|
||||||
|
make.leading.trailing.equalTo(preShootCard)
|
||||||
|
}
|
||||||
|
fieldsStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(preOrderCard.snp.bottom).offset(18)
|
||||||
|
make.leading.trailing.equalTo(preShootCard)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(fieldsStack.snp.bottom).offset(22)
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
make.width.equalTo(confirmButton)
|
||||||
|
make.bottom.equalToSuperview().offset(-16)
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.top.height.width.equalTo(cancelButton)
|
||||||
|
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
preShootCard.addTarget(self, action: #selector(preShootTapped), for: .touchUpInside)
|
||||||
|
preOrderCard.addTarget(self, action: #selector(preOrderTapped), for: .touchUpInside)
|
||||||
|
orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside)
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
[freeCountField, singlePriceField, packagePriceField].forEach {
|
||||||
|
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
if isBeingDismissed {
|
||||||
|
onDismissed?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTextField(_ field: UITextField, placeholder: String, keyboardType: UIKeyboardType) {
|
||||||
|
field.placeholder = placeholder
|
||||||
|
field.keyboardType = keyboardType
|
||||||
|
field.font = .systemFont(ofSize: 14)
|
||||||
|
field.layer.cornerRadius = 8
|
||||||
|
field.layer.borderWidth = 1
|
||||||
|
field.layer.borderColor = AppColor.border.cgColor
|
||||||
|
field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||||||
|
field.leftViewMode = .always
|
||||||
|
field.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureActionButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setTitleColor(titleColor, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
button.backgroundColor = backgroundColor
|
||||||
|
button.layer.cornerRadius = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildFields() {
|
||||||
|
fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
if mode == .preShoot {
|
||||||
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
|
||||||
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
|
||||||
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
|
||||||
|
} else {
|
||||||
|
fieldsStack.addArrangedSubview(makeOrderGroup())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
|
||||||
|
let container = UIView()
|
||||||
|
let label = UILabel()
|
||||||
|
label.attributedText = fieldTitle(title, required: required)
|
||||||
|
container.addSubview(label)
|
||||||
|
container.addSubview(field)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
field.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(label.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeOrderGroup() -> UIView {
|
||||||
|
let container = UIView()
|
||||||
|
let label = UILabel()
|
||||||
|
label.attributedText = fieldTitle("绑定订单", required: false)
|
||||||
|
container.addSubview(label)
|
||||||
|
container.addSubview(orderButton)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
orderButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(label.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
make.height.greaterThanOrEqualTo(46)
|
||||||
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fieldTitle(_ text: String, required: Bool) -> NSAttributedString {
|
||||||
|
let result = NSMutableAttributedString(
|
||||||
|
string: text,
|
||||||
|
attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium), .foregroundColor: AppColor.textPrimary]
|
||||||
|
)
|
||||||
|
if required {
|
||||||
|
result.append(NSAttributedString(string: " *", attributes: [.foregroundColor: UIColor.red]))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMode(_ newMode: TravelAlbumEntryViewModel.CreateMode) {
|
||||||
|
mode = newMode
|
||||||
|
preShootCard.apply(
|
||||||
|
title: "先拍再买",
|
||||||
|
desc: "拍完后分享给用户,用户在小程序上选择性购买",
|
||||||
|
selected: mode == .preShoot
|
||||||
|
)
|
||||||
|
preOrderCard.apply(
|
||||||
|
title: "买了再拍",
|
||||||
|
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
|
||||||
|
selected: mode == .preOrder
|
||||||
|
)
|
||||||
|
rebuildFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateOrderButton() {
|
||||||
|
guard let order = selectedOrder else {
|
||||||
|
orderButton.setTitle("请选择订单", for: .normal)
|
||||||
|
orderButton.setTitleColor(AppColor.textTertiary, for: .normal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName)\n手机号:\(order.userPhone)\n订单号:\(order.orderNumber)"
|
||||||
|
orderButton.setTitle(title, for: .normal)
|
||||||
|
orderButton.setTitleColor(AppColor.textPrimary, for: .normal)
|
||||||
|
orderButton.titleLabel?.numberOfLines = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func preShootTapped() {
|
||||||
|
updateMode(.preShoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func preOrderTapped() {
|
||||||
|
updateMode(.preOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func orderTapped() {
|
||||||
|
let alert = UIAlertController(title: "绑定订单", message: nil, preferredStyle: .actionSheet)
|
||||||
|
if viewModel.availableOrders.isEmpty {
|
||||||
|
alert.addAction(UIAlertAction(title: "暂无可绑定订单", style: .default))
|
||||||
|
} else {
|
||||||
|
viewModel.availableOrders.forEach { order in
|
||||||
|
let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName) \(order.userPhone)"
|
||||||
|
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||||
|
self?.selectedOrder = order
|
||||||
|
self?.updateOrderButton()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
Task {
|
||||||
|
await viewModel.confirmCreate(
|
||||||
|
mode: mode,
|
||||||
|
freeCount: freeCountField.text ?? "",
|
||||||
|
singlePrice: singlePriceField.text ?? "",
|
||||||
|
packagePrice: packagePriceField.text ?? "",
|
||||||
|
order: selectedOrder,
|
||||||
|
api: api
|
||||||
|
)
|
||||||
|
await MainActor.run {
|
||||||
|
if !viewModel.isCreateSheetVisible {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func textFieldEditingChanged(_ field: UITextField) {
|
||||||
|
let text = field.text ?? ""
|
||||||
|
if field === freeCountField {
|
||||||
|
field.text = text.filter(\.isNumber)
|
||||||
|
} else {
|
||||||
|
field.text = sanitizeMoneyInput(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sanitizeMoneyInput(_ value: String) -> String {
|
||||||
|
var result = ""
|
||||||
|
var hasDot = false
|
||||||
|
for char in value {
|
||||||
|
if char.isNumber {
|
||||||
|
result.append(char)
|
||||||
|
} else if char == ".", !hasDot {
|
||||||
|
result.append(char)
|
||||||
|
hasDot = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !result.isEmpty else { return "" }
|
||||||
|
if result.hasPrefix(".") {
|
||||||
|
result = "0" + result
|
||||||
|
}
|
||||||
|
guard let dotIndex = result.firstIndex(of: ".") else {
|
||||||
|
let trimmed = result.drop { $0 == "0" }
|
||||||
|
return trimmed.isEmpty ? "0" : String(trimmed)
|
||||||
|
}
|
||||||
|
let integerPart = result[..<dotIndex].drop { $0 == "0" }
|
||||||
|
let decimalStart = result.index(after: dotIndex)
|
||||||
|
let decimal = result[decimalStart...].prefix(2)
|
||||||
|
return "\(integerPart.isEmpty ? "0" : String(integerPart)).\(decimal)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新建相册模式选择卡片。
|
||||||
|
private final class TravelAlbumModeOptionView: UIControl {
|
||||||
|
private let dotView = UIView()
|
||||||
|
private let innerDotView = UIView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let descLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
layer.cornerRadius = 10
|
||||||
|
layer.borderWidth = 1
|
||||||
|
dotView.layer.cornerRadius = 10
|
||||||
|
innerDotView.backgroundColor = .white
|
||||||
|
innerDotView.layer.cornerRadius = 3.5
|
||||||
|
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
descLabel.font = .systemFont(ofSize: 12)
|
||||||
|
descLabel.textColor = AppColor.textSecondary
|
||||||
|
descLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
addSubview(dotView)
|
||||||
|
dotView.addSubview(innerDotView)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(descLabel)
|
||||||
|
dotView.snp.makeConstraints { make in
|
||||||
|
make.leading.top.equalToSuperview().offset(14)
|
||||||
|
make.size.equalTo(20)
|
||||||
|
}
|
||||||
|
innerDotView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(7)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(14)
|
||||||
|
make.leading.equalTo(dotView.snp.trailing).offset(10)
|
||||||
|
make.trailing.equalToSuperview().offset(-14)
|
||||||
|
}
|
||||||
|
descLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.trailing.equalTo(titleLabel)
|
||||||
|
make.bottom.equalToSuperview().offset(-14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(title: String, desc: String, selected: Bool) {
|
||||||
|
titleLabel.text = title
|
||||||
|
descLabel.text = desc
|
||||||
|
backgroundColor = selected ? AppColor.primary.withAlphaComponent(0.06) : .white
|
||||||
|
layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
|
||||||
|
titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
|
||||||
|
dotView.backgroundColor = selected ? AppColor.primary : .clear
|
||||||
|
dotView.layer.borderWidth = selected ? 0 : 2
|
||||||
|
dotView.layer.borderColor = AppColor.border.cgColor
|
||||||
|
innerDotView.isHidden = !selected
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumCodeDialogViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 相册码弹窗,对齐 Android `TravelAlbumCodeDialog`。
|
||||||
|
final class TravelAlbumCodeDialogViewController: UIViewController {
|
||||||
|
var onClose: (() -> Void)?
|
||||||
|
var onDownload: (() -> Void)?
|
||||||
|
|
||||||
|
private let state: TravelAlbumEntryViewModel.AlbumCodeState
|
||||||
|
private let dimView = UIView()
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let closeButton = UIButton(type: .system)
|
||||||
|
private let albumNameLabel = UILabel()
|
||||||
|
private let qrImageView = UIImageView()
|
||||||
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let hintLabel = UILabel()
|
||||||
|
private let downloadButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
init(state: TravelAlbumEntryViewModel.AlbumCodeState) {
|
||||||
|
self.state = state
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .overFullScreen
|
||||||
|
modalTransitionStyle = .crossDissolve
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
setupUI()
|
||||||
|
setupConstraints()
|
||||||
|
applyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 16
|
||||||
|
cardView.clipsToBounds = true
|
||||||
|
|
||||||
|
titleLabel.text = "相册码"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||||
|
closeButton.tintColor = AppColor.textSecondary
|
||||||
|
albumNameLabel.font = .systemFont(ofSize: 18, weight: .medium)
|
||||||
|
albumNameLabel.textColor = AppColor.textPrimary
|
||||||
|
albumNameLabel.textAlignment = .center
|
||||||
|
qrImageView.contentMode = .scaleAspectFit
|
||||||
|
qrImageView.layer.cornerRadius = 8
|
||||||
|
qrImageView.clipsToBounds = true
|
||||||
|
hintLabel.textAlignment = .center
|
||||||
|
hintLabel.font = .systemFont(ofSize: 13)
|
||||||
|
hintLabel.textColor = AppColor.textSecondary
|
||||||
|
hintLabel.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
|
||||||
|
hintLabel.layer.cornerRadius = 8
|
||||||
|
hintLabel.layer.borderWidth = 1
|
||||||
|
hintLabel.layer.borderColor = AppColor.primary.withAlphaComponent(0.35).cgColor
|
||||||
|
hintLabel.clipsToBounds = true
|
||||||
|
let hint = NSMutableAttributedString(string: "微信", attributes: [.foregroundColor: AppColor.textSecondary])
|
||||||
|
hint.append(NSAttributedString(string: "扫一扫", attributes: [.foregroundColor: AppColor.primary, .font: UIFont.systemFont(ofSize: 13, weight: .medium)]))
|
||||||
|
hint.append(NSAttributedString(string: ",查看下载照片", attributes: [.foregroundColor: AppColor.textSecondary]))
|
||||||
|
hintLabel.attributedText = hint
|
||||||
|
downloadButton.setTitle("下载图片", for: .normal)
|
||||||
|
downloadButton.setTitleColor(.white, for: .normal)
|
||||||
|
downloadButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
downloadButton.backgroundColor = AppColor.primary
|
||||||
|
downloadButton.layer.cornerRadius = 23
|
||||||
|
|
||||||
|
view.addSubview(dimView)
|
||||||
|
view.addSubview(cardView)
|
||||||
|
[titleLabel, closeButton, albumNameLabel, qrImageView, loadingIndicator, hintLabel, downloadButton].forEach(cardView.addSubview)
|
||||||
|
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||||
|
downloadButton.addTarget(self, action: #selector(downloadTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
dimView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.width.equalToSuperview().multipliedBy(0.78)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(18)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(48)
|
||||||
|
}
|
||||||
|
closeButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(titleLabel)
|
||||||
|
make.trailing.equalToSuperview().offset(-20)
|
||||||
|
make.size.equalTo(28)
|
||||||
|
}
|
||||||
|
albumNameLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(18)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
}
|
||||||
|
qrImageView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(albumNameLabel.snp.bottom).offset(18)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.size.equalTo(190)
|
||||||
|
}
|
||||||
|
loadingIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(qrImageView)
|
||||||
|
}
|
||||||
|
hintLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(qrImageView.snp.bottom).offset(18)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.height.equalTo(34)
|
||||||
|
make.leading.greaterThanOrEqualToSuperview().offset(20)
|
||||||
|
}
|
||||||
|
downloadButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(hintLabel.snp.bottom).offset(20)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
make.height.equalTo(46)
|
||||||
|
make.bottom.equalToSuperview().offset(-18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyState() {
|
||||||
|
albumNameLabel.text = state.albumName.isEmpty ? "未命名相册" : state.albumName
|
||||||
|
if state.isLoading {
|
||||||
|
loadingIndicator.startAnimating()
|
||||||
|
qrImageView.image = nil
|
||||||
|
} else {
|
||||||
|
loadingIndicator.stopAnimating()
|
||||||
|
if let url = URL(string: state.qrImageUrl), !state.qrImageUrl.isEmpty {
|
||||||
|
qrImageView.kf.setImage(with: url)
|
||||||
|
} else {
|
||||||
|
qrImageView.image = UIImage(systemName: "qrcode")
|
||||||
|
qrImageView.tintColor = AppColor.textSecondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloadButton.isEnabled = !state.isLoading && !state.qrImageUrl.isEmpty
|
||||||
|
downloadButton.alpha = downloadButton.isEnabled ? 1 : 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDownloading(_ isDownloading: Bool) {
|
||||||
|
downloadButton.setTitle(isDownloading ? "保存中..." : "下载图片", for: .normal)
|
||||||
|
downloadButton.isEnabled = !isDownloading
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closeTapped() {
|
||||||
|
onClose?()
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func downloadTapped() {
|
||||||
|
onDownload?()
|
||||||
|
}
|
||||||
|
}
|
||||||
479
suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
Normal file
479
suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumDetailViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 相册详情管理页,对齐 Android `TravelAlbumDetailScreen`。
|
||||||
|
final class TravelAlbumDetailViewController: BaseViewController {
|
||||||
|
private let viewModel: TravelAlbumDetailViewModel
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
|
||||||
|
private let scrollContainer = UIView()
|
||||||
|
private let infoCard = TravelAlbumInfoCard()
|
||||||
|
private let manageCard = UIView()
|
||||||
|
private let tabStack = UIStackView()
|
||||||
|
private let allTabButton = UIButton(type: .system)
|
||||||
|
private let purchasedTabButton = UIButton(type: .system)
|
||||||
|
private let sortButton = UIButton(type: .system)
|
||||||
|
private let selectButton = UIButton(type: .system)
|
||||||
|
private var collectionView: UICollectionView!
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let deleteSelectedButton = UIButton(type: .system)
|
||||||
|
private let uploadButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
|
||||||
|
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
||||||
|
self.api = api
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "相册管理"
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
image: UIImage(systemName: "ellipsis"),
|
||||||
|
menu: UIMenu(children: [
|
||||||
|
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||||||
|
self?.confirmDeleteAlbum()
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackground
|
||||||
|
|
||||||
|
manageCard.backgroundColor = .white
|
||||||
|
manageCard.layer.cornerRadius = 12
|
||||||
|
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
|
||||||
|
manageCard.layer.shadowOpacity = 1
|
||||||
|
manageCard.layer.shadowRadius = 6
|
||||||
|
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
|
|
||||||
|
tabStack.axis = .horizontal
|
||||||
|
tabStack.spacing = 8
|
||||||
|
configurePillButton(allTabButton)
|
||||||
|
configurePillButton(purchasedTabButton)
|
||||||
|
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill"))
|
||||||
|
configureIconButton(selectButton, image: UIImage(systemName: "circle"))
|
||||||
|
|
||||||
|
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
|
collectionView.backgroundColor = .white
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier)
|
||||||
|
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
|
||||||
|
[weak self] collectionView, indexPath, material in
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumMaterialCell
|
||||||
|
cell.apply(
|
||||||
|
material: material,
|
||||||
|
selectionMode: self?.viewModel.isSelectionMode == true,
|
||||||
|
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true
|
||||||
|
)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = AppColor.pageBackground
|
||||||
|
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
|
||||||
|
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
|
||||||
|
deleteSelectedButton.isHidden = true
|
||||||
|
|
||||||
|
view.addSubview(scrollContainer)
|
||||||
|
scrollContainer.addSubview(infoCard)
|
||||||
|
scrollContainer.addSubview(manageCard)
|
||||||
|
manageCard.addSubview(tabStack)
|
||||||
|
tabStack.addArrangedSubview(allTabButton)
|
||||||
|
tabStack.addArrangedSubview(purchasedTabButton)
|
||||||
|
manageCard.addSubview(sortButton)
|
||||||
|
manageCard.addSubview(selectButton)
|
||||||
|
manageCard.addSubview(collectionView)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(deleteSelectedButton)
|
||||||
|
bottomBar.addSubview(uploadButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
deleteSelectedButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
uploadButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(deleteSelectedButton.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.equalTo(deleteSelectedButton)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
|
}
|
||||||
|
scrollContainer.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
|
||||||
|
}
|
||||||
|
infoCard.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.greaterThanOrEqualTo(76)
|
||||||
|
}
|
||||||
|
manageCard.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(infoCard.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
tabStack.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().offset(12)
|
||||||
|
make.height.equalTo(32)
|
||||||
|
}
|
||||||
|
sortButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(tabStack)
|
||||||
|
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
|
||||||
|
make.size.equalTo(32)
|
||||||
|
}
|
||||||
|
selectButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(tabStack)
|
||||||
|
make.trailing.equalToSuperview().offset(-12)
|
||||||
|
make.size.equalTo(32)
|
||||||
|
}
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(tabStack.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
infoCard.onCall = { [weak self] phone in self?.call(phone) }
|
||||||
|
allTabButton.addTarget(self, action: #selector(allTabTapped), 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)
|
||||||
|
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
|
||||||
|
uploadButton.addTarget(self, action: #selector(uploadTapped), 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.onDeletedAlbum = { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await viewModel.refreshAll(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
if let album = viewModel.album {
|
||||||
|
infoCard.apply(album: album)
|
||||||
|
}
|
||||||
|
configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
|
||||||
|
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased)
|
||||||
|
selectButton.isHidden = viewModel.selectedTab != .all
|
||||||
|
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
|
||||||
|
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
|
||||||
|
deleteSelectedButton.isHidden = !(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
|
||||||
|
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
snapshot.appendItems(viewModel.materials)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
||||||
|
if viewModel.isLoading && viewModel.album == nil {
|
||||||
|
showLoading()
|
||||||
|
} else {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configurePillButton(_ button: UIButton) {
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
button.layer.cornerRadius = 16
|
||||||
|
button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF)
|
||||||
|
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureIconButton(_ button: UIButton, image: UIImage?) {
|
||||||
|
button.setImage(image, for: .normal)
|
||||||
|
button.backgroundColor = UIColor(hex: 0xEAF4FF)
|
||||||
|
button.tintColor = AppColor.primary
|
||||||
|
button.layer.cornerRadius = 16
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setTitleColor(.white, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
button.backgroundColor = color
|
||||||
|
button.layer.cornerRadius = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||||
|
UICollectionViewCompositionalLayout { _, environment in
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3
|
||||||
|
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38))
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 38))
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
|
||||||
|
group.interItemSpacing = .fixed(spacing)
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.interGroupSpacing = 12
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func call(_ phone: String) {
|
||||||
|
guard !phone.isEmpty, let url = URL(string: "tel:\(phone)") else { return }
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDeleteAlbum() {
|
||||||
|
let alert = UIAlertController(title: "删除相册", message: "确定删除该相册吗?已有购买素材的相册无法删除。", preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.viewModel.deleteAlbum(api: self.api) }
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func allTabTapped() {
|
||||||
|
Task { await viewModel.selectTab(.all, api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func purchasedTabTapped() {
|
||||||
|
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() {
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deleteSelectedTapped() {
|
||||||
|
let count = viewModel.selectedMaterialIds.count
|
||||||
|
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.viewModel.deleteSelectedMaterials(api: self.api) }
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func uploadTapped() {
|
||||||
|
let album = viewModel.album
|
||||||
|
let controller = WiredCameraTransferViewController(
|
||||||
|
viewModel: WiredCameraTransferViewModel(
|
||||||
|
albumId: album?.id ?? viewModel.albumId,
|
||||||
|
albumTitle: album?.name ?? "",
|
||||||
|
headerPhone: album?.displayPhone ?? ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
viewModel.toggleMaterialSelection(material)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
let visible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? 0
|
||||||
|
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: visible, api: api) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册详情信息卡。
|
||||||
|
private final class TravelAlbumInfoCard: UIView {
|
||||||
|
var onCall: ((String) -> Void)?
|
||||||
|
private var phone = ""
|
||||||
|
|
||||||
|
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
|
||||||
|
private let phoneLabel = UILabel()
|
||||||
|
private let timeLabel = UILabel()
|
||||||
|
private let callButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = 12
|
||||||
|
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
|
||||||
|
layer.shadowOpacity = 1
|
||||||
|
layer.shadowRadius = 6
|
||||||
|
layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
|
|
||||||
|
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.tintColor = AppColor.primary
|
||||||
|
callButton.backgroundColor = UIColor(hex: 0xEAF4FF)
|
||||||
|
callButton.layer.cornerRadius = 20
|
||||||
|
|
||||||
|
addSubview(iconBox)
|
||||||
|
iconBox.addSubview(iconView)
|
||||||
|
addSubview(phoneLabel)
|
||||||
|
addSubview(timeLabel)
|
||||||
|
addSubview(callButton)
|
||||||
|
iconBox.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview().inset(14)
|
||||||
|
make.size.equalTo(48)
|
||||||
|
}
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(28)
|
||||||
|
}
|
||||||
|
phoneLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(16)
|
||||||
|
make.leading.equalTo(iconBox.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalTo(callButton.snp.leading).offset(-12)
|
||||||
|
}
|
||||||
|
timeLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
|
||||||
|
make.leading.trailing.equalTo(phoneLabel)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(album: TravelAlbum) {
|
||||||
|
phone = album.displayPhone
|
||||||
|
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))"
|
||||||
|
timeLabel.text = "创建时间 \(album.createdAt)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func callTapped() {
|
||||||
|
onCall?(phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册素材网格 cell。
|
||||||
|
private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||||
|
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||||
|
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let checkImageView = UIImageView()
|
||||||
|
private let nameLabel = UILabel()
|
||||||
|
private let sizeLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
imageView.layer.cornerRadius = 8
|
||||||
|
statusLabel.text = "已上传"
|
||||||
|
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.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
|
checkImageView.layer.cornerRadius = 10
|
||||||
|
nameLabel.font = .systemFont(ofSize: 11)
|
||||||
|
nameLabel.textColor = AppColor.textPrimary
|
||||||
|
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
|
sizeLabel.font = .systemFont(ofSize: 10)
|
||||||
|
sizeLabel.textColor = AppColor.textTertiary
|
||||||
|
|
||||||
|
contentView.addSubview(imageView)
|
||||||
|
imageView.addSubview(statusLabel)
|
||||||
|
imageView.addSubview(checkImageView)
|
||||||
|
contentView.addSubview(nameLabel)
|
||||||
|
contentView.addSubview(sizeLabel)
|
||||||
|
imageView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
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
|
||||||
|
make.top.trailing.equalToSuperview().inset(4)
|
||||||
|
make.size.equalTo(20)
|
||||||
|
}
|
||||||
|
nameLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(imageView.snp.bottom).offset(4)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
sizeLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(nameLabel.snp.bottom).offset(2)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
|
||||||
|
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
|
||||||
|
if let url = URL(string: urlString), !urlString.isEmpty {
|
||||||
|
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
|
||||||
|
} else {
|
||||||
|
imageView.image = UIImage(systemName: "photo")
|
||||||
|
imageView.tintColor = AppColor.primary
|
||||||
|
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
|
||||||
|
}
|
||||||
|
checkImageView.isHidden = !selectionMode
|
||||||
|
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
|
checkImageView.tintColor = selected ? AppColor.primary : .white
|
||||||
|
nameLabel.text = material.fileName
|
||||||
|
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
456
suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift
Normal file
456
suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumEntryViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 新增相册入口页,对齐 Android `TravelAlbumEntryScreen`。
|
||||||
|
final class TravelAlbumEntryViewController: BaseViewController {
|
||||||
|
private let viewModel = TravelAlbumEntryViewModel()
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
|
||||||
|
private let heroCard = TravelAlbumHeroCard()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let emptyView = TravelAlbumEmptyView()
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
||||||
|
|
||||||
|
init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
|
||||||
|
self.api = api
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "新增相册"
|
||||||
|
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = .white
|
||||||
|
|
||||||
|
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
titleLabel.textColor = .black
|
||||||
|
|
||||||
|
tableView.backgroundColor = .white
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 132
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
|
||||||
|
|
||||||
|
dataSource = UITableViewDiffableDataSource<Int, TravelAlbum>(tableView: tableView) {
|
||||||
|
[weak self] tableView, indexPath, album in
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: TravelAlbumTaskCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumTaskCell
|
||||||
|
cell.apply(album: album)
|
||||||
|
cell.onShootUpload = { [weak self] in self?.pushWiredTransfer(album: album) }
|
||||||
|
cell.onAlbumCode = { [weak self] in self?.loadAlbumCode(album) }
|
||||||
|
cell.onShare = { [weak self] in self?.showShareSheet() }
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
view.addSubview(heroCard)
|
||||||
|
view.addSubview(titleLabel)
|
||||||
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(emptyView)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
heroCard.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
make.height.equalTo(108)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(heroCard.snp.bottom).offset(28)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
}
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
emptyView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(heroCard.snp.bottom)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await viewModel.loadAlbums(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
heroCard.isLoading = viewModel.isCreating
|
||||||
|
titleLabel.text = "我的任务(\(viewModel.albumTotal))"
|
||||||
|
titleLabel.isHidden = viewModel.albums.isEmpty
|
||||||
|
tableView.isHidden = viewModel.albums.isEmpty
|
||||||
|
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
snapshot.appendItems(viewModel.albums)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
if viewModel.isLoading && viewModel.albums.isEmpty {
|
||||||
|
showLoading()
|
||||||
|
} else {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func createTapped() {
|
||||||
|
Task {
|
||||||
|
await viewModel.openCreateSheet(api: api)
|
||||||
|
await MainActor.run {
|
||||||
|
guard viewModel.isCreateSheetVisible, presentedViewController == nil else { return }
|
||||||
|
let sheet = CreateTravelAlbumSheetViewController(viewModel: viewModel, api: api)
|
||||||
|
sheet.onDismissed = { [weak self] in self?.viewModel.dismissCreateSheet() }
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadAlbumCode(_ album: TravelAlbum) {
|
||||||
|
Task {
|
||||||
|
await viewModel.openAlbumCode(album: album, api: api)
|
||||||
|
await MainActor.run { self.presentAlbumCodeIfNeeded() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func presentAlbumCodeIfNeeded() {
|
||||||
|
guard let state = viewModel.albumCodeState else { return }
|
||||||
|
let controller = TravelAlbumCodeDialogViewController(state: state)
|
||||||
|
controller.onClose = { [weak self] in self?.viewModel.dismissAlbumCode() }
|
||||||
|
controller.onDownload = { [weak self, weak controller] in
|
||||||
|
guard let self, let url = self.viewModel.albumCodeState?.qrImageUrl, !url.isEmpty else { return }
|
||||||
|
self.viewModel.setDownloadingQRCode(true)
|
||||||
|
controller?.updateDownloading(true)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await ScenicQueueQRCodeSaver.saveImage(from: url)
|
||||||
|
await MainActor.run { self.showToast("已保存到相册") }
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { self.showToast(error.localizedDescription) }
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
self.viewModel.setDownloadingQRCode(false)
|
||||||
|
controller?.updateDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
present(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showShareSheet() {
|
||||||
|
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||||
|
sheet.addAction(UIAlertAction(title: "微信好友", style: .default) { [weak self] _ in
|
||||||
|
self?.showToast("已选择微信好友")
|
||||||
|
})
|
||||||
|
sheet.addAction(UIAlertAction(title: "QQ好友", style: .default) { [weak self] _ in
|
||||||
|
self?.showToast("已选择QQ好友")
|
||||||
|
})
|
||||||
|
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pushWiredTransfer(album: TravelAlbum) {
|
||||||
|
let controller = WiredCameraTransferViewController(
|
||||||
|
viewModel: WiredCameraTransferViewModel(
|
||||||
|
albumId: album.id,
|
||||||
|
albumTitle: album.name,
|
||||||
|
headerPhone: album.displayPhone
|
||||||
|
)
|
||||||
|
)
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumEntryViewController: UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard let album = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: album.id), animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新建相册任务蓝色入口卡片。
|
||||||
|
private final class TravelAlbumHeroCard: UIControl {
|
||||||
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
private let plusView = UIImageView(image: UIImage(systemName: "plus"))
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let subtitleLabel = UILabel()
|
||||||
|
private let arrowLabel = UILabel()
|
||||||
|
|
||||||
|
var isLoading = false {
|
||||||
|
didSet { titleLabel.text = isLoading ? "新建中..." : "新建相册任务" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
layer.cornerRadius = 10
|
||||||
|
clipsToBounds = true
|
||||||
|
gradientLayer.colors = [UIColor(hex: 0x007BFF).cgColor, UIColor(hex: 0x3A91FF).cgColor]
|
||||||
|
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||||||
|
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||||||
|
layer.insertSublayer(gradientLayer, at: 0)
|
||||||
|
|
||||||
|
plusView.tintColor = AppColor.primary
|
||||||
|
plusView.contentMode = .scaleAspectFit
|
||||||
|
plusView.backgroundColor = .white
|
||||||
|
plusView.layer.cornerRadius = 17
|
||||||
|
|
||||||
|
titleLabel.text = "新建相册任务"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 19, weight: .medium)
|
||||||
|
titleLabel.textColor = .white
|
||||||
|
subtitleLabel.text = "创建一个新的相册拍摄任务"
|
||||||
|
subtitleLabel.font = .systemFont(ofSize: 14)
|
||||||
|
subtitleLabel.textColor = .white
|
||||||
|
arrowLabel.text = "›"
|
||||||
|
arrowLabel.textColor = .white
|
||||||
|
arrowLabel.font = .systemFont(ofSize: 36, weight: .light)
|
||||||
|
|
||||||
|
addSubview(plusView)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(subtitleLabel)
|
||||||
|
addSubview(arrowLabel)
|
||||||
|
plusView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(28)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(34)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(30)
|
||||||
|
make.leading.equalTo(plusView.snp.trailing).offset(16)
|
||||||
|
make.trailing.lessThanOrEqualTo(arrowLabel.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
subtitleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||||
|
make.leading.equalTo(titleLabel)
|
||||||
|
make.trailing.lessThanOrEqualTo(arrowLabel.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
arrowLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().offset(-18)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
gradientLayer.frame = bounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册任务列表 cell。
|
||||||
|
private final class TravelAlbumTaskCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "TravelAlbumTaskCell"
|
||||||
|
|
||||||
|
var onShootUpload: (() -> Void)?
|
||||||
|
var onAlbumCode: (() -> Void)?
|
||||||
|
var onShare: (() -> Void)?
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let coverView = UIImageView()
|
||||||
|
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
|
||||||
|
private let nameLabel = UILabel()
|
||||||
|
private let timeLabel = UILabel()
|
||||||
|
private let phoneLabel = UILabel()
|
||||||
|
private let shareButton = UIButton(type: .system)
|
||||||
|
private let codeButton = UIButton(type: .system)
|
||||||
|
private let uploadButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .white
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 10
|
||||||
|
cardView.layer.shadowColor = UIColor(hex: 0x2F6BFF).withAlphaComponent(0.10).cgColor
|
||||||
|
cardView.layer.shadowOpacity = 1
|
||||||
|
cardView.layer.shadowRadius = 10
|
||||||
|
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||||
|
|
||||||
|
coverView.backgroundColor = UIColor(hex: 0xEAF2FF)
|
||||||
|
coverView.layer.cornerRadius = 8
|
||||||
|
coverView.clipsToBounds = true
|
||||||
|
coverView.contentMode = .scaleAspectFill
|
||||||
|
iconView.tintColor = AppColor.primary
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
nameLabel.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
|
nameLabel.textColor = AppColor.textPrimary
|
||||||
|
timeLabel.font = .systemFont(ofSize: 12)
|
||||||
|
timeLabel.textColor = AppColor.textTertiary
|
||||||
|
phoneLabel.font = .systemFont(ofSize: 12)
|
||||||
|
phoneLabel.textColor = AppColor.textTertiary
|
||||||
|
|
||||||
|
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
|
||||||
|
shareButton.tintColor = AppColor.primary
|
||||||
|
codeButton.setImage(UIImage(systemName: "qrcode"), for: .normal)
|
||||||
|
codeButton.tintColor = .black
|
||||||
|
uploadButton.setImage(UIImage(systemName: "camera.fill"), for: .normal)
|
||||||
|
uploadButton.setTitle(" 拍摄上传", for: .normal)
|
||||||
|
uploadButton.tintColor = AppColor.primary
|
||||||
|
uploadButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
uploadButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .bold)
|
||||||
|
uploadButton.backgroundColor = UIColor(hex: 0xEAF4FF)
|
||||||
|
uploadButton.layer.cornerRadius = 8
|
||||||
|
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(coverView)
|
||||||
|
coverView.addSubview(iconView)
|
||||||
|
cardView.addSubview(nameLabel)
|
||||||
|
cardView.addSubview(timeLabel)
|
||||||
|
cardView.addSubview(phoneLabel)
|
||||||
|
cardView.addSubview(shareButton)
|
||||||
|
cardView.addSubview(codeButton)
|
||||||
|
cardView.addSubview(uploadButton)
|
||||||
|
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20))
|
||||||
|
}
|
||||||
|
coverView.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview().inset(10)
|
||||||
|
make.size.equalTo(86)
|
||||||
|
}
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(42)
|
||||||
|
}
|
||||||
|
shareButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(12)
|
||||||
|
make.trailing.equalTo(codeButton.snp.leading).offset(-8)
|
||||||
|
make.size.equalTo(26)
|
||||||
|
}
|
||||||
|
codeButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(shareButton)
|
||||||
|
make.trailing.equalToSuperview().offset(-10)
|
||||||
|
make.size.equalTo(26)
|
||||||
|
}
|
||||||
|
nameLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(14)
|
||||||
|
make.leading.equalTo(coverView.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalTo(shareButton.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
timeLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(nameLabel.snp.bottom).offset(7)
|
||||||
|
make.leading.trailing.equalTo(nameLabel)
|
||||||
|
}
|
||||||
|
phoneLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(timeLabel.snp.bottom).offset(5)
|
||||||
|
make.leading.trailing.equalTo(nameLabel)
|
||||||
|
}
|
||||||
|
uploadButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(phoneLabel.snp.bottom).offset(9)
|
||||||
|
make.trailing.equalToSuperview().offset(-10)
|
||||||
|
make.bottom.equalToSuperview().offset(-10)
|
||||||
|
make.height.equalTo(34)
|
||||||
|
make.width.greaterThanOrEqualTo(104)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
|
||||||
|
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||||
|
codeButton.addTarget(self, action: #selector(codeTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(album: TravelAlbum) {
|
||||||
|
nameLabel.text = album.name.isEmpty ? "未命名任务" : album.name
|
||||||
|
timeLabel.text = "创建时间:\(TravelAlbumDisplayFormatter.shortTaskTime(album.createdAt))"
|
||||||
|
phoneLabel.text = "手机号:\(album.displayPhone.isEmpty ? "--" : album.displayPhone)"
|
||||||
|
if let url = URL(string: album.coverUrl), !album.coverUrl.isEmpty {
|
||||||
|
iconView.isHidden = true
|
||||||
|
coverView.kf.setImage(with: url)
|
||||||
|
} else {
|
||||||
|
coverView.image = nil
|
||||||
|
iconView.isHidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func uploadTapped() { onShootUpload?() }
|
||||||
|
@objc private func shareTapped() { onShare?() }
|
||||||
|
@objc private func codeTapped() { onAlbumCode?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册空态视图。
|
||||||
|
private final class TravelAlbumEmptyView: UIView {
|
||||||
|
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let subtitleLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
isHidden = true
|
||||||
|
imageView.tintColor = AppColor.primary
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
titleLabel.text = "暂无任务"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
titleLabel.textColor = .black
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
subtitleLabel.text = "点击“新建相册任务”开始创建任务"
|
||||||
|
subtitleLabel.font = .systemFont(ofSize: 14)
|
||||||
|
subtitleLabel.textColor = AppColor.textTertiary
|
||||||
|
subtitleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
let backdrop = UIView()
|
||||||
|
backdrop.backgroundColor = UIColor(hex: 0xF2F7FF)
|
||||||
|
backdrop.layer.cornerRadius = 54
|
||||||
|
addSubview(backdrop)
|
||||||
|
addSubview(imageView)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(subtitleLabel)
|
||||||
|
backdrop.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview().offset(-70)
|
||||||
|
make.width.equalToSuperview().multipliedBy(0.58)
|
||||||
|
make.height.equalTo(108)
|
||||||
|
}
|
||||||
|
imageView.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(backdrop)
|
||||||
|
make.size.equalTo(82)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(backdrop.snp.bottom).offset(22)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(20)
|
||||||
|
}
|
||||||
|
subtitleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(10)
|
||||||
|
make.leading.trailing.equalTo(titleLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
311
suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift
Normal file
311
suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
//
|
||||||
|
// WiredCameraTransferViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 有线相机传输页,仅同步 Android 断开空态 UI,不接入 OTG 或上传业务。
|
||||||
|
final class WiredCameraTransferViewController: BaseViewController {
|
||||||
|
private let viewModel: WiredCameraTransferViewModel
|
||||||
|
|
||||||
|
private let headerView = UIView()
|
||||||
|
private let backButton = UIButton(type: .system)
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let phoneLabel = UILabel()
|
||||||
|
private let statusCard = UIView()
|
||||||
|
private let storagePanel = UIView()
|
||||||
|
private let storageTitleLabel = UILabel()
|
||||||
|
private let storageValueLabel = UILabel()
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let refreshButton = UIButton(type: .system)
|
||||||
|
private let helpLabel = UILabel()
|
||||||
|
private let chipsStack = UIStackView()
|
||||||
|
private let statsCard = UIStackView()
|
||||||
|
private let emptyLabel = UILabel()
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let batchButton = UIButton(type: .system)
|
||||||
|
private let specifyButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
init(viewModel: WiredCameraTransferViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackground
|
||||||
|
headerView.backgroundColor = AppColor.primary
|
||||||
|
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||||
|
backButton.tintColor = .white
|
||||||
|
titleLabel.text = viewModel.albumTitle
|
||||||
|
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
|
||||||
|
titleLabel.textColor = .white
|
||||||
|
phoneLabel.text = viewModel.headerPhone
|
||||||
|
phoneLabel.font = .systemFont(ofSize: 13)
|
||||||
|
phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92)
|
||||||
|
|
||||||
|
statusCard.backgroundColor = .white
|
||||||
|
statusCard.layer.cornerRadius = 12
|
||||||
|
statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor
|
||||||
|
statusCard.layer.shadowOpacity = 1
|
||||||
|
statusCard.layer.shadowRadius = 8
|
||||||
|
statusCard.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||||
|
|
||||||
|
storagePanel.layer.cornerRadius = 12
|
||||||
|
storagePanel.layer.borderColor = AppColor.border.cgColor
|
||||||
|
storagePanel.layer.borderWidth = 3
|
||||||
|
storageTitleLabel.text = viewModel.deviceModelName
|
||||||
|
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
|
storageTitleLabel.textColor = AppColor.textPrimary
|
||||||
|
storageTitleLabel.textAlignment = .center
|
||||||
|
storageValueLabel.text = viewModel.availableStorageText
|
||||||
|
storageValueLabel.font = .systemFont(ofSize: 10)
|
||||||
|
storageValueLabel.textColor = AppColor.primary
|
||||||
|
storageValueLabel.textAlignment = .center
|
||||||
|
|
||||||
|
statusLabel.text = viewModel.cameraStatusText
|
||||||
|
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
|
statusLabel.textColor = AppColor.danger
|
||||||
|
statusLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.10)
|
||||||
|
statusLabel.layer.cornerRadius = 4
|
||||||
|
statusLabel.clipsToBounds = true
|
||||||
|
statusLabel.textAlignment = .center
|
||||||
|
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
|
||||||
|
refreshButton.setTitleColor(.white, for: .normal)
|
||||||
|
refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
|
||||||
|
refreshButton.backgroundColor = AppColor.danger
|
||||||
|
refreshButton.layer.cornerRadius = 8
|
||||||
|
helpLabel.attributedText = helpText()
|
||||||
|
helpLabel.font = .systemFont(ofSize: 10)
|
||||||
|
helpLabel.numberOfLines = 2
|
||||||
|
|
||||||
|
chipsStack.axis = .horizontal
|
||||||
|
chipsStack.spacing = 6
|
||||||
|
[viewModel.retouchOption, viewModel.photoFormatOption, viewModel.transferModeOption].forEach {
|
||||||
|
chipsStack.addArrangedSubview(makeChip($0))
|
||||||
|
}
|
||||||
|
|
||||||
|
statsCard.axis = .horizontal
|
||||||
|
statsCard.distribution = .fillEqually
|
||||||
|
["全部", "已上传", "失败"].enumerated().forEach { index, title in
|
||||||
|
statsCard.addArrangedSubview(makeStat(title: title, count: viewModel.tabCounts[index], selected: index == 0, danger: index == 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyLabel.text = "暂无照片"
|
||||||
|
emptyLabel.font = .systemFont(ofSize: 14)
|
||||||
|
emptyLabel.textColor = AppColor.textTertiary
|
||||||
|
emptyLabel.textAlignment = .center
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
configureBottomButton(batchButton, title: "批量上传", filled: true)
|
||||||
|
configureBottomButton(specifyButton, title: "指定上传", filled: false)
|
||||||
|
|
||||||
|
view.addSubview(headerView)
|
||||||
|
headerView.addSubview(backButton)
|
||||||
|
headerView.addSubview(titleLabel)
|
||||||
|
headerView.addSubview(phoneLabel)
|
||||||
|
view.addSubview(statusCard)
|
||||||
|
statusCard.addSubview(storagePanel)
|
||||||
|
storagePanel.addSubview(storageTitleLabel)
|
||||||
|
storagePanel.addSubview(storageValueLabel)
|
||||||
|
statusCard.addSubview(statusLabel)
|
||||||
|
statusCard.addSubview(refreshButton)
|
||||||
|
statusCard.addSubview(helpLabel)
|
||||||
|
statusCard.addSubview(chipsStack)
|
||||||
|
statusCard.addSubview(statsCard)
|
||||||
|
view.addSubview(emptyLabel)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(batchButton)
|
||||||
|
bottomBar.addSubview(specifyButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
headerView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(118)
|
||||||
|
}
|
||||||
|
backButton.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(8)
|
||||||
|
make.bottom.equalToSuperview().offset(-28)
|
||||||
|
make.size.equalTo(44)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(backButton.snp.trailing).offset(4)
|
||||||
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
|
make.top.equalTo(backButton).offset(4)
|
||||||
|
}
|
||||||
|
phoneLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(2)
|
||||||
|
make.leading.trailing.equalTo(titleLabel)
|
||||||
|
}
|
||||||
|
statusCard.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(headerView.snp.bottom).offset(-10)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
storagePanel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().offset(14)
|
||||||
|
make.width.greaterThanOrEqualTo(76)
|
||||||
|
make.height.equalTo(76)
|
||||||
|
}
|
||||||
|
storageTitleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(10)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(8)
|
||||||
|
}
|
||||||
|
storageValueLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(storageTitleLabel.snp.bottom).offset(6)
|
||||||
|
make.leading.trailing.equalTo(storageTitleLabel)
|
||||||
|
}
|
||||||
|
statusLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(16)
|
||||||
|
make.leading.equalTo(storagePanel.snp.trailing).offset(10)
|
||||||
|
make.height.equalTo(22)
|
||||||
|
make.width.greaterThanOrEqualTo(84)
|
||||||
|
}
|
||||||
|
refreshButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(statusLabel)
|
||||||
|
make.trailing.equalToSuperview().offset(-14)
|
||||||
|
make.height.equalTo(34)
|
||||||
|
make.width.greaterThanOrEqualTo(72)
|
||||||
|
}
|
||||||
|
helpLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.equalTo(statusLabel)
|
||||||
|
make.trailing.equalTo(refreshButton)
|
||||||
|
}
|
||||||
|
chipsStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(storagePanel.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(14)
|
||||||
|
make.height.equalTo(34)
|
||||||
|
}
|
||||||
|
statsCard.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(chipsStack.snp.bottom).offset(14)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(68)
|
||||||
|
}
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
batchButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(12)
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.height.equalTo(44)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
|
}
|
||||||
|
specifyButton.snp.makeConstraints { make in
|
||||||
|
make.top.height.width.equalTo(batchButton)
|
||||||
|
make.leading.equalTo(batchButton.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalToSuperview().offset(-16)
|
||||||
|
}
|
||||||
|
emptyLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusCard.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||||
|
refreshButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
|
||||||
|
batchButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
|
||||||
|
specifyButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeChip(_ text: String) -> UIView {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text
|
||||||
|
label.font = .systemFont(ofSize: 11)
|
||||||
|
label.textColor = AppColor.textTertiary
|
||||||
|
label.backgroundColor = AppColor.pageBackground
|
||||||
|
label.layer.cornerRadius = 8
|
||||||
|
label.layer.borderWidth = 1
|
||||||
|
label.layer.borderColor = AppColor.border.cgColor
|
||||||
|
label.clipsToBounds = true
|
||||||
|
label.textAlignment = .center
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStat(title: String, count: Int, selected: Bool, danger: Bool) -> UIView {
|
||||||
|
let container = UIView()
|
||||||
|
let countLabel = UILabel()
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
let underline = UIView()
|
||||||
|
countLabel.text = "\(count)"
|
||||||
|
countLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
countLabel.textColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
|
||||||
|
countLabel.textAlignment = .center
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .systemFont(ofSize: 13)
|
||||||
|
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
underline.backgroundColor = selected ? AppColor.primary : .clear
|
||||||
|
underline.layer.cornerRadius = 1
|
||||||
|
container.addSubview(countLabel)
|
||||||
|
container.addSubview(titleLabel)
|
||||||
|
container.addSubview(underline)
|
||||||
|
countLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(8)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(countLabel.snp.bottom).offset(2)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
underline.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.width.equalTo(24)
|
||||||
|
make.height.equalTo(2)
|
||||||
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 15)
|
||||||
|
button.layer.cornerRadius = 10
|
||||||
|
if filled {
|
||||||
|
button.backgroundColor = AppColor.primary
|
||||||
|
button.setTitleColor(.white, for: .normal)
|
||||||
|
} else {
|
||||||
|
button.backgroundColor = .white
|
||||||
|
button.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
button.layer.borderWidth = 1
|
||||||
|
button.layer.borderColor = AppColor.primary.cgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func helpText() -> NSAttributedString {
|
||||||
|
let text = NSMutableAttributedString(
|
||||||
|
string: "未连接,查看",
|
||||||
|
attributes: [.foregroundColor: AppColor.textSecondary]
|
||||||
|
)
|
||||||
|
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func backTapped() {
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func uiOnlyTapped() {
|
||||||
|
viewModel.showUIOnlyMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
81
suixinkanTests/TravelAlbumAPITests.swift
Normal file
81
suixinkanTests/TravelAlbumAPITests.swift
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumAPITests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 旅拍相册 API 测试。
|
||||||
|
final class TravelAlbumAPITests: XCTestCase {
|
||||||
|
func testListBuildsPathAndQuery() async throws {
|
||||||
|
let data = envelopeJSON(#"{"total":1,"list":[]}"#)
|
||||||
|
let session = MockURLSession(responses: [data])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
_ = try await api.list(page: 2, pageSize: 30)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/list")
|
||||||
|
let query = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems)
|
||||||
|
XCTAssertEqual(query.first { $0.name == "page" }?.value, "2")
|
||||||
|
XCTAssertEqual(query.first { $0.name == "page_size" }?.value, "30")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateEncodesBody() async throws {
|
||||||
|
let data = envelopeJSON(#"{"id":8}"#)
|
||||||
|
let session = MockURLSession(responses: [data])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
_ = try await api.create(
|
||||||
|
TravelAlbumCreateRequest(
|
||||||
|
name: "2026-07-07-001",
|
||||||
|
type: 1,
|
||||||
|
orderNumber: nil,
|
||||||
|
materialNum: 2,
|
||||||
|
materialPrice: 10.5,
|
||||||
|
materialPackagePrice: 88,
|
||||||
|
photoPrice: 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "POST")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/create")
|
||||||
|
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||||
|
XCTAssertEqual(body?["name"] as? String, "2026-07-07-001")
|
||||||
|
XCTAssertEqual(body?["type"] as? Int, 1)
|
||||||
|
XCTAssertEqual(body?["material_num"] as? Int, 2)
|
||||||
|
XCTAssertEqual(body?["material_price"] as? Double, 10.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMaterialListAndDeleteAndMpCode() async throws {
|
||||||
|
let materialList = envelopeJSON(#"{"total":0,"list":[]}"#)
|
||||||
|
let empty = envelopeJSON("{}")
|
||||||
|
let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
|
||||||
|
let session = MockURLSession(responses: [materialList, empty, code])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
_ = try await api.materialList(userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1)
|
||||||
|
try await api.deleteAlbum(id: 3)
|
||||||
|
let response = try await api.mpCode(id: 3)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png")
|
||||||
|
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
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "order_by" }?.value, "4")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "is_purchased" }?.value, "1")
|
||||||
|
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/travel-album/delete")
|
||||||
|
let body = try JSONSerialization.jsonObject(with: session.requests[1].httpBody!) as? [String: Any]
|
||||||
|
XCTAssertEqual(body?["id"] as? Int, 3)
|
||||||
|
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||||
|
"""
|
||||||
|
{"code":100000,"msg":"success","data":\(dataJSON)}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
}
|
||||||
|
}
|
||||||
92
suixinkanTests/TravelAlbumModelsTests.swift
Normal file
92
suixinkanTests/TravelAlbumModelsTests.swift
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumModelsTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 旅拍相册模型与展示工具测试。
|
||||||
|
final class TravelAlbumModelsTests: XCTestCase {
|
||||||
|
func testTravelAlbumDecodesSnakeCaseFields() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"store_user_id": 2,
|
||||||
|
"name": "旅拍相册",
|
||||||
|
"type": 1,
|
||||||
|
"order_number": "NO001",
|
||||||
|
"material_num": 3,
|
||||||
|
"material_price": 1200,
|
||||||
|
"material_package_price": 5000,
|
||||||
|
"photo_price": 0,
|
||||||
|
"cover_url": "https://cdn/cover.jpg",
|
||||||
|
"user_id": 9,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2026-07-07 10:00:00",
|
||||||
|
"updated_at": "2026-07-07 10:10:00",
|
||||||
|
"user": {"id": 4, "phone": "13800138000"}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let album = try JSONDecoder().decode(TravelAlbum.self, from: json)
|
||||||
|
XCTAssertEqual(album.id, 7)
|
||||||
|
XCTAssertEqual(album.storeUserId, 2)
|
||||||
|
XCTAssertEqual(album.orderNumber, "NO001")
|
||||||
|
XCTAssertEqual(album.displayPhone, "13800138000")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTravelAlbumMaterialDecodesSnakeCaseFields() 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,
|
||||||
|
"created_at": "",
|
||||||
|
"updated_at": ""
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json)
|
||||||
|
XCTAssertEqual(material.userEquityTravelId, 7)
|
||||||
|
XCTAssertEqual(material.fileName, "IMG_0001.JPG")
|
||||||
|
XCTAssertFalse(material.isPurchased)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayFormatters() {
|
||||||
|
XCTAssertEqual(TravelAlbumDisplayFormatter.maskPhone("13800138000"), "138****8000")
|
||||||
|
XCTAssertEqual(TravelAlbumDisplayFormatter.maskPhone(""), "--")
|
||||||
|
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2048), "2.0KB")
|
||||||
|
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2 * 1024 * 1024), "2.0MB")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testBuildTodayAlbumNameUsesExistingSameDayCount() {
|
||||||
|
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
|
||||||
|
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date })
|
||||||
|
viewModel.onStateChange = {}
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.listResponse = TravelAlbumListResponse(
|
||||||
|
total: 2,
|
||||||
|
list: [
|
||||||
|
TravelAlbum(id: 1, createdAt: "2026-07-07 09:00:00"),
|
||||||
|
TravelAlbum(id: 2, createdAt: "2026-07-06 09:00:00"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let exp = expectation(description: "load")
|
||||||
|
Task {
|
||||||
|
await viewModel.loadAlbums(api: api)
|
||||||
|
XCTAssertEqual(viewModel.buildTodayAlbumName(), "2026-07-07-002")
|
||||||
|
exp.fulfill()
|
||||||
|
}
|
||||||
|
wait(for: [exp], timeout: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
221
suixinkanTests/TravelAlbumViewModelTests.swift
Normal file
221
suixinkanTests/TravelAlbumViewModelTests.swift
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 旅拍相册 ViewModel 测试。
|
||||||
|
final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||||
|
func testLoadAlbumsUpdatesListAndTotal() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
|
||||||
|
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||||
|
|
||||||
|
await viewModel.loadAlbums(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.albumTotal, 1)
|
||||||
|
XCTAssertEqual(viewModel.albums.first?.name, "A")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreShootRequiresSinglePrice() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||||
|
var message = ""
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
await viewModel.confirmCreate(mode: .preShoot, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(message, "请输入单张照片价格")
|
||||||
|
XCTAssertEqual(api.createRequests.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreOrderRequiresOrder() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||||
|
var message = ""
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(message, "请选择绑定订单")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateSuccessRefreshesAlbums() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.createResponse = TravelAlbumCreateResponse(id: 9)
|
||||||
|
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 9)])
|
||||||
|
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
|
||||||
|
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date })
|
||||||
|
|
||||||
|
await viewModel.confirmCreate(mode: .preShoot, freeCount: "2", singlePrice: "12.5", packagePrice: "88", order: nil, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.createRequests.first?.name, "2026-07-07-001")
|
||||||
|
XCTAssertEqual(api.createRequests.first?.type, 1)
|
||||||
|
XCTAssertEqual(viewModel.albums.first?.id, 9)
|
||||||
|
XCTAssertFalse(viewModel.isCreateSheetVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOrderAlreadyBoundReloadsOrders() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.createError = APIError.serverCode(100001, "订单已关联")
|
||||||
|
api.availableOrdersResponse = [TravelAlbumAvailableOrder(projectName: "新订单", orderNumber: "NO1")]
|
||||||
|
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||||
|
let order = TravelAlbumAvailableOrder(projectName: "项目", orderNumber: "NO0")
|
||||||
|
|
||||||
|
await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: order, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.availableOrdersCallCount, 1)
|
||||||
|
XCTAssertEqual(viewModel.availableOrders.first?.projectName, "新订单")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 旅拍相册详情 ViewModel 测试。
|
||||||
|
final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||||
|
func testRefreshLoadsInfoAndMaterials() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.infoResponse = TravelAlbum(id: 2, name: "详情")
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(total: 2, list: []),
|
||||||
|
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
|
||||||
|
await viewModel.refreshAll(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.album?.name, "详情")
|
||||||
|
XCTAssertEqual(viewModel.allPhotoCount, 2)
|
||||||
|
XCTAssertEqual(viewModel.materials.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTabAndSortTriggerMaterialRequests() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
|
||||||
|
await viewModel.selectTab(.purchased, api: api)
|
||||||
|
await viewModel.setSortOption(.fileNameDesc, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.materialRequests.first?.isPurchased, 1)
|
||||||
|
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnlyUnpurchasedMaterialCanBeSelected() {
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2))
|
||||||
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
|
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
|
||||||
|
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteAlbumCallsCallback() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
|
var deletedId = 0
|
||||||
|
viewModel.onDeletedAlbum = { deletedId = $0 }
|
||||||
|
|
||||||
|
await viewModel.deleteAlbum(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedAlbumIds, [5])
|
||||||
|
XCTAssertEqual(deletedId, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有线传输 ViewModel 测试。
|
||||||
|
final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||||
|
func testDefaultDisconnectedStateDoesNotDependOnBusinessServices() {
|
||||||
|
let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000")
|
||||||
|
var message = ""
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
|
||||||
|
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
|
||||||
|
viewModel.showUIOnlyMessage()
|
||||||
|
XCTAssertEqual(message, "仅同步 UI,暂未接入 OTG 传输")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 旅拍相册 API 测试替身。
|
||||||
|
final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||||
|
struct MaterialRequest: Equatable {
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
let orderBy: Int
|
||||||
|
let isPurchased: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
|
||||||
|
var listResponse = TravelAlbumListResponse<TravelAlbum>()
|
||||||
|
var createResponse = TravelAlbumCreateResponse(id: 0)
|
||||||
|
var infoResponse = TravelAlbum()
|
||||||
|
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
||||||
|
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
||||||
|
var createError: Error?
|
||||||
|
|
||||||
|
private(set) var availableOrdersCallCount = 0
|
||||||
|
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
||||||
|
private(set) var materialRequests: [MaterialRequest] = []
|
||||||
|
private(set) var deletedAlbumIds: [Int] = []
|
||||||
|
private(set) var deletedMaterialIds: [Int] = []
|
||||||
|
|
||||||
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||||
|
availableOrdersCallCount += 1
|
||||||
|
return availableOrdersResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
|
||||||
|
createRequests.append(request)
|
||||||
|
if let createError { throw createError }
|
||||||
|
return createResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
|
||||||
|
listResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(id: Int) async throws -> TravelAlbum {
|
||||||
|
infoResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func materialList(
|
||||||
|
userEquityTravelId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
orderBy: Int,
|
||||||
|
isPurchased: Int?
|
||||||
|
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
|
||||||
|
materialRequests.append(
|
||||||
|
MaterialRequest(
|
||||||
|
userEquityTravelId: userEquityTravelId,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
orderBy: orderBy,
|
||||||
|
isPurchased: isPurchased
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
|
||||||
|
return materialListResponses.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAlbum(id: Int) async throws {
|
||||||
|
deletedAlbumIds.append(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMaterial(id: Int) async throws {
|
||||||
|
deletedMaterialIds.append(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
||||||
|
mpCodeResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user