From 00bda390e82749ba9ac805552b828a8a48d18611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 7 Jul 2026 17:23:31 +0800 Subject: [PATCH] feat: add travel album module --- suixinkan/App/NetworkServices.swift | 3 + .../Home/Services/HomeMenuCatalog.swift | 1 + .../Home/Services/HomeRouteHandler.swift | 2 + .../TravelAlbum/API/TravelAlbumAPI.swift | 139 +++++ .../Models/TravelAlbumModels.swift | 256 ++++++++++ .../TravelAlbumDetailViewModel.swift | 231 +++++++++ .../TravelAlbumEntryViewModel.swift | 252 +++++++++ .../WiredCameraTransferViewModel.swift | 36 ++ ...CreateTravelAlbumSheetViewController.swift | 393 ++++++++++++++ .../TravelAlbumCodeDialogViewController.swift | 162 ++++++ .../TravelAlbumDetailViewController.swift | 479 ++++++++++++++++++ .../TravelAlbumEntryViewController.swift | 456 +++++++++++++++++ .../WiredCameraTransferViewController.swift | 311 ++++++++++++ suixinkanTests/TravelAlbumAPITests.swift | 81 +++ suixinkanTests/TravelAlbumModelsTests.swift | 92 ++++ .../TravelAlbumViewModelTests.swift | 221 ++++++++ 16 files changed, 3115 insertions(+) create mode 100644 suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift create mode 100644 suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift create mode 100644 suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift create mode 100644 suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift create mode 100644 suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift create mode 100644 suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift create mode 100644 suixinkan/UI/TravelAlbum/TravelAlbumCodeDialogViewController.swift create mode 100644 suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift create mode 100644 suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift create mode 100644 suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift create mode 100644 suixinkanTests/TravelAlbumAPITests.swift create mode 100644 suixinkanTests/TravelAlbumModelsTests.swift create mode 100644 suixinkanTests/TravelAlbumViewModelTests.swift diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 5cdde1d..2287862 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -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) } diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index cd68a14..0d28ac8 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -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"), diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index a3a2b59..7f7e2a9 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -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: diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift new file mode 100644 index 0000000..e6556b4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -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 + + /// 拉取旅拍相册详情。 + func info(id: Int) async throws -> TravelAlbum + + /// 拉取旅拍相册素材列表。 + func materialList( + userEquityTravelId: Int, + page: Int, + pageSize: Int, + orderBy: Int, + isPurchased: Int? + ) async throws -> TravelAlbumListResponse + + /// 删除旅拍相册。 + 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 { + 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 { + 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 +} diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift new file mode 100644 index 0000000..9e609e2 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -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: 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 + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift new file mode 100644 index 0000000..a4e5297 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift @@ -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 = [] + + 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?() + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift new file mode 100644 index 0000000..de13303 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift @@ -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 + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift new file mode 100644 index 0000000..8c69607 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -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 传输") + } +} diff --git a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift new file mode 100644 index 0000000..d31d672 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift @@ -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[.. 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?() + } +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift new file mode 100644 index 0000000..b748d5f --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -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! + 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(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() + 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) + } +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift new file mode 100644 index 0000000..bd69ef8 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift @@ -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! + + 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(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() + 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") + } +} diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift new file mode 100644 index 0000000..12c2170 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -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() + } +} diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift new file mode 100644 index 0000000..beb5c9c --- /dev/null +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -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)! + } +} diff --git a/suixinkanTests/TravelAlbumModelsTests.swift b/suixinkanTests/TravelAlbumModelsTests.swift new file mode 100644 index 0000000..5f6f631 --- /dev/null +++ b/suixinkanTests/TravelAlbumModelsTests.swift @@ -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) + } +} diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift new file mode 100644 index 0000000..0a3e474 --- /dev/null +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -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() + var createResponse = TravelAlbumCreateResponse(id: 0) + var infoResponse = TravelAlbum() + var materialListResponses: [TravelAlbumListResponse] = [] + 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 { + listResponse + } + + func info(id: Int) async throws -> TravelAlbum { + infoResponse + } + + func materialList( + userEquityTravelId: Int, + page: Int, + pageSize: Int, + orderBy: Int, + isPurchased: Int? + ) async throws -> TravelAlbumListResponse { + 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 + } +}