feat: add travel album module
This commit is contained in:
@ -21,6 +21,7 @@ final class NetworkServices {
|
||||
let inviteAPI: InviteAPI
|
||||
let walletAPI: WalletAPI
|
||||
let scenicQueueAPI: ScenicQueueAPI
|
||||
let travelAlbumAPI: TravelAlbumAPI
|
||||
let uploadAPI: UploadAPI
|
||||
let ossUploadService: OSSUploadService
|
||||
|
||||
@ -37,6 +38,7 @@ final class NetworkServices {
|
||||
inviteAPI = InviteAPI(client: client)
|
||||
walletAPI = WalletAPI(client: client)
|
||||
scenicQueueAPI = ScenicQueueAPI(client: client)
|
||||
travelAlbumAPI = TravelAlbumAPI(client: client)
|
||||
uploadAPI = UploadAPI(client: client)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
client.bindAuthTokenProvider {
|
||||
@ -58,6 +60,7 @@ final class NetworkServices {
|
||||
inviteAPI = InviteAPI(client: apiClient)
|
||||
walletAPI = WalletAPI(client: apiClient)
|
||||
scenicQueueAPI = ScenicQueueAPI(client: apiClient)
|
||||
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
|
||||
uploadAPI = UploadAPI(client: apiClient)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ enum HomeMenuCatalog {
|
||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"),
|
||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"),
|
||||
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: "pm", title: "项目管理", iconName: "folder"),
|
||||
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),
|
||||
|
||||
@ -58,6 +58,8 @@ enum HomeRouteHandler {
|
||||
viewController.navigationController?.pushViewController(ScenicQueueViewController(), animated: true)
|
||||
case "task_management", "task_management_editor":
|
||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
case "travel_album":
|
||||
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
|
||||
case "wallet":
|
||||
showDeveloping(from: viewController)
|
||||
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