feat: add travel album module

This commit is contained in:
2026-07-07 17:23:31 +08:00
parent a80c181bd7
commit 00bda390e8
16 changed files with 3115 additions and 0 deletions

View File

@ -21,6 +21,7 @@ final class NetworkServices {
let inviteAPI: InviteAPI let inviteAPI: InviteAPI
let walletAPI: WalletAPI let walletAPI: WalletAPI
let scenicQueueAPI: ScenicQueueAPI let scenicQueueAPI: ScenicQueueAPI
let travelAlbumAPI: TravelAlbumAPI
let uploadAPI: UploadAPI let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService let ossUploadService: OSSUploadService
@ -37,6 +38,7 @@ final class NetworkServices {
inviteAPI = InviteAPI(client: client) inviteAPI = InviteAPI(client: client)
walletAPI = WalletAPI(client: client) walletAPI = WalletAPI(client: client)
scenicQueueAPI = ScenicQueueAPI(client: client) scenicQueueAPI = ScenicQueueAPI(client: client)
travelAlbumAPI = TravelAlbumAPI(client: client)
uploadAPI = UploadAPI(client: client) uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
client.bindAuthTokenProvider { client.bindAuthTokenProvider {
@ -58,6 +60,7 @@ final class NetworkServices {
inviteAPI = InviteAPI(client: apiClient) inviteAPI = InviteAPI(client: apiClient)
walletAPI = WalletAPI(client: apiClient) walletAPI = WalletAPI(client: apiClient)
scenicQueueAPI = ScenicQueueAPI(client: apiClient) scenicQueueAPI = ScenicQueueAPI(client: apiClient)
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
} }

View File

@ -23,6 +23,7 @@ enum HomeMenuCatalog {
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"), HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"),
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"), HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"),
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"), HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"),
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),
HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "photo.on.rectangle.angled"), HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "photo.on.rectangle.angled"),
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"), HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"),
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"), HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),

View File

@ -58,6 +58,8 @@ enum HomeRouteHandler {
viewController.navigationController?.pushViewController(ScenicQueueViewController(), animated: true) viewController.navigationController?.pushViewController(ScenicQueueViewController(), animated: true)
case "task_management", "task_management_editor": case "task_management", "task_management_editor":
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true) viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
case "travel_album":
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
case "wallet": case "wallet":
showDeveloping(from: viewController) showDeveloping(from: viewController)
default: default:

View File

@ -0,0 +1,139 @@
//
// TravelAlbumAPI.swift
// suixinkan
//
import Foundation
@MainActor
///
protocol TravelAlbumServing {
///
func availableOrders() async throws -> [TravelAlbumAvailableOrder]
///
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse
///
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum>
///
func info(id: Int) async throws -> TravelAlbum
///
func materialList(
userEquityTravelId: Int,
page: Int,
pageSize: Int,
orderBy: Int,
isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
///
func deleteAlbum(id: Int) async throws
///
func deleteMaterial(id: Int) async throws
///
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
}
@MainActor
/// API Android `photog/travel-album`
final class TravelAlbumAPI: TravelAlbumServing {
private let client: APIClient
private let basePath = "/api/yf-handset-app/photog/travel-album"
/// API
init(client: APIClient) {
self.client = client
}
///
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
try await client.send(APIRequest(method: .get, path: "\(basePath)/available-order"))
}
///
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
try await client.send(APIRequest(method: .post, path: "\(basePath)/create", body: request))
}
///
func list(page: Int = 1, pageSize: Int = 20) async throws -> TravelAlbumListResponse<TravelAlbum> {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/list",
queryItems: [
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
)
)
}
///
func info(id: Int) async throws -> TravelAlbum {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/info",
queryItems: [URLQueryItem(name: "id", value: String(id))]
)
)
}
///
func materialList(
userEquityTravelId: Int,
page: Int = 1,
pageSize: Int = 20,
orderBy: Int = 2,
isPurchased: Int? = nil
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
var items = [
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
URLQueryItem(name: "order_by", value: String(orderBy)),
]
if let isPurchased {
items.append(URLQueryItem(name: "is_purchased", value: String(isPurchased)))
}
return try await client.send(
APIRequest(method: .get, path: "\(basePath)/material-list", queryItems: items)
)
}
///
func deleteAlbum(id: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/delete", body: TravelAlbumIDRequest(id: id))
)
}
///
func deleteMaterial(id: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id))
)
}
///
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/mp-code",
queryItems: [URLQueryItem(name: "id", value: String(id))]
)
)
}
}
/// ID
private struct TravelAlbumIDRequest: Encodable, Sendable {
let id: Int
}

View File

@ -0,0 +1,256 @@
//
// TravelAlbumModels.swift
// suixinkan
//
import Foundation
/// Android `TravelAlbumUserEntity`
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
let id: Int
let phone: String
init(id: Int = 0, phone: String = "") {
self.id = id
self.phone = phone
}
}
/// Android `TravelAlbumEntity`
struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let storeUserId: Int
let name: String
let type: Int
let orderNumber: String
let materialNum: Int
let materialPrice: Int
let materialPackagePrice: Int
let photoPrice: Int
let coverUrl: String
let userId: Int
let status: Int
let createdAt: String
let updatedAt: String
let user: TravelAlbumUser?
enum CodingKeys: String, CodingKey {
case id
case storeUserId = "store_user_id"
case name
case type
case orderNumber = "order_number"
case materialNum = "material_num"
case materialPrice = "material_price"
case materialPackagePrice = "material_package_price"
case photoPrice = "photo_price"
case coverUrl = "cover_url"
case userId = "user_id"
case status
case createdAt = "created_at"
case updatedAt = "updated_at"
case user
}
init(
id: Int = 0,
storeUserId: Int = 0,
name: String = "",
type: Int = 0,
orderNumber: String = "",
materialNum: Int = 0,
materialPrice: Int = 0,
materialPackagePrice: Int = 0,
photoPrice: Int = 0,
coverUrl: String = "",
userId: Int = 0,
status: Int = 0,
createdAt: String = "",
updatedAt: String = "",
user: TravelAlbumUser? = nil
) {
self.id = id
self.storeUserId = storeUserId
self.name = name
self.type = type
self.orderNumber = orderNumber
self.materialNum = materialNum
self.materialPrice = materialPrice
self.materialPackagePrice = materialPackagePrice
self.photoPrice = photoPrice
self.coverUrl = coverUrl
self.userId = userId
self.status = status
self.createdAt = createdAt
self.updatedAt = updatedAt
self.user = user
}
///
var displayPhone: String {
user?.phone ?? ""
}
}
/// Android `TravelAlbumAvailableOrderEntity`
struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
let projectName: String
let orderNumber: String
let userPhone: String
let userId: Int
enum CodingKeys: String, CodingKey {
case projectName = "project_name"
case orderNumber = "order_number"
case userPhone = "user_phone"
case userId = "user_id"
}
init(projectName: String = "", orderNumber: String = "", userPhone: String = "", userId: Int = 0) {
self.projectName = projectName
self.orderNumber = orderNumber
self.userPhone = userPhone
self.userId = userId
}
}
/// Android `TravelAlbumMaterialEntity`
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let userEquityTravelId: Int
let status: Int
let orderNumber: String
let userId: Int
let fileName: String
let fileType: Int
let fileUrl: String
let fileSize: Int
let coverUrl: String
let isPurchased: Bool
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id
case userEquityTravelId = "user_equity_travel_id"
case status
case orderNumber = "order_number"
case userId = "user_id"
case fileName = "file_name"
case fileType = "file_type"
case fileUrl = "file_url"
case fileSize = "file_size"
case coverUrl = "cover_url"
case isPurchased = "is_purchased"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
init(
id: Int = 0,
userEquityTravelId: Int = 0,
status: Int = 0,
orderNumber: String = "",
userId: Int = 0,
fileName: String = "",
fileType: Int = 0,
fileUrl: String = "",
fileSize: Int = 0,
coverUrl: String = "",
isPurchased: Bool = false,
createdAt: String = "",
updatedAt: String = ""
) {
self.id = id
self.userEquityTravelId = userEquityTravelId
self.status = status
self.orderNumber = orderNumber
self.userId = userId
self.fileName = fileName
self.fileType = fileType
self.fileUrl = fileUrl
self.fileSize = fileSize
self.coverUrl = coverUrl
self.isPurchased = isPurchased
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
/// Android `TravelAlbumCreateRequest`
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
let name: String
let type: Int
let orderNumber: String?
let materialNum: Int?
let materialPrice: Double?
let materialPackagePrice: Double?
let photoPrice: Double?
enum CodingKeys: String, CodingKey {
case name
case type
case orderNumber = "order_number"
case materialNum = "material_num"
case materialPrice = "material_price"
case materialPackagePrice = "material_package_price"
case photoPrice = "photo_price"
}
}
///
struct TravelAlbumListResponse<Item: Decodable & Sendable & Equatable>: Decodable, Sendable, Equatable {
let total: Int
let list: [Item]
init(total: Int = 0, list: [Item] = []) {
self.total = total
self.list = list
}
}
///
struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
let id: Int
}
///
struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
let mpCodeOssUrl: String
enum CodingKeys: String, CodingKey {
case mpCodeOssUrl = "mp_code_oss_url"
}
}
///
enum TravelAlbumDisplayFormatter {
///
static func maskPhone(_ phone: String) -> String {
guard phone.count >= 7 else { return phone.isEmpty ? "--" : phone }
return "\(phone.prefix(3))****\(phone.suffix(4))"
}
///
static func fileSizeText(_ bytes: Int) -> String {
let value = Double(max(bytes, 0))
if value >= 1024 * 1024 * 1024 {
return String(format: "%.1fGB", value / 1024 / 1024 / 1024)
}
if value >= 1024 * 1024 {
return String(format: "%.1fMB", value / 1024 / 1024)
}
if value >= 1024 {
return String(format: "%.1fKB", value / 1024)
}
return "\(Int(value))B"
}
///
static func shortTaskTime(_ text: String) -> String {
if text.count >= 16 {
return String(text.prefix(16))
}
return text
}
}

View File

@ -0,0 +1,231 @@
//
// TravelAlbumDetailViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class TravelAlbumDetailViewModel {
enum Tab: Int, Sendable {
case all = 0
case purchased = 1
}
///
enum SortOption: Int, CaseIterable, Sendable {
case createdAsc = 1
case createdDesc = 2
case fileNameAsc = 3
case fileNameDesc = 4
var title: String {
switch self {
case .createdAsc: "创建时间正序"
case .createdDesc: "创建时间倒序"
case .fileNameAsc: "文件名正序"
case .fileNameDesc: "文件名倒序"
}
}
}
private(set) var album: TravelAlbum?
private(set) var materials: [TravelAlbumMaterial] = []
private(set) var allPhotoCount = 0
private(set) var selectedTab: Tab = .all
private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true
private(set) var isRefreshing = false
private(set) var isSelectionMode = false
private(set) var selectedMaterialIds: Set<Int> = []
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onDeletedAlbum: ((Int) -> Void)?
let albumId: Int
private var currentPage = 1
private var canLoadMore = false
private var isLoadingMore = false
private let pageSize = 30
init(albumId: Int) {
self.albumId = albumId
}
///
func refreshAll(api: any TravelAlbumServing) async {
guard albumId > 0 else {
isLoading = false
onShowMessage?("相册不存在")
notifyStateChange()
return
}
isRefreshing = true
notifyStateChange()
await loadAlbumInfo(api: api)
await loadAllPhotoCount(api: api)
await loadMaterials(reset: true, api: api)
isRefreshing = false
isLoading = false
notifyStateChange()
}
///
func loadAlbumInfo(api: any TravelAlbumServing) async {
do {
album = try await api.info(id: albumId)
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func loadAllPhotoCount(api: any TravelAlbumServing) async {
do {
let response = try await api.materialList(
userEquityTravelId: albumId,
page: 1,
pageSize: 1,
orderBy: sortOption.rawValue,
isPurchased: nil
)
allPhotoCount = response.total
notifyStateChange()
} catch is CancellationError {
return
} catch {
return
}
}
/// tab
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return }
selectedTab = tab
isSelectionMode = false
selectedMaterialIds = []
notifyStateChange()
await loadMaterials(reset: true, api: api)
}
///
func setSortOption(_ option: SortOption, api: any TravelAlbumServing) async {
guard sortOption != option else { return }
sortOption = option
notifyStateChange()
await loadMaterials(reset: true, api: api)
}
///
func loadMaterials(reset: Bool, api: any TravelAlbumServing) async {
guard albumId > 0 else { return }
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoadingMore else { return }
isLoadingMore = true
currentPage += 1
}
do {
let response = try await api.materialList(
userEquityTravelId: albumId,
page: currentPage,
pageSize: pageSize,
orderBy: sortOption.rawValue,
isPurchased: selectedTab == .purchased ? 1 : nil
)
materials = reset ? response.list : materials + response.list
canLoadMore = materials.count < response.total
if selectedTab == .all {
allPhotoCount = response.total
}
isLoadingMore = false
notifyStateChange()
} catch is CancellationError {
return
} catch {
if !reset && currentPage > 1 {
currentPage -= 1
}
isLoadingMore = false
onShowMessage?(error.localizedDescription)
}
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async {
if lastVisibleIndex >= materials.count - 6 {
await loadMaterials(reset: false, api: api)
}
}
///
func toggleSelectionMode() {
guard selectedTab == .all else { return }
isSelectionMode.toggle()
if !isSelectionMode {
selectedMaterialIds = []
}
notifyStateChange()
}
///
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
guard isSelectionMode else { return }
guard material.status == 1 else {
onShowMessage?("仅未购买素材可删除")
return
}
if selectedMaterialIds.contains(material.id) {
selectedMaterialIds.remove(material.id)
} else {
selectedMaterialIds.insert(material.id)
}
notifyStateChange()
}
///
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
let ids = Array(selectedMaterialIds)
guard !ids.isEmpty else {
onShowMessage?("请选择要删除的素材")
return
}
do {
for id in ids {
try await api.deleteMaterial(id: id)
}
onShowMessage?("删除成功")
isSelectionMode = false
selectedMaterialIds = []
await refreshAll(api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
}
}
///
func deleteAlbum(api: any TravelAlbumServing) async {
do {
try await api.deleteAlbum(id: albumId)
onShowMessage?("相册已删除")
onDeletedAlbum?(albumId)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -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
}
}

View File

@ -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 传输")
}
}

View File

@ -0,0 +1,393 @@
//
// CreateTravelAlbumSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Sheet Android `CreateTravelAlbumModal`
final class CreateTravelAlbumSheetViewController: BaseViewController {
var onDismissed: (() -> Void)?
private let viewModel: TravelAlbumEntryViewModel
private let api: any TravelAlbumServing
private var mode: TravelAlbumEntryViewModel.CreateMode = .preShoot
private var selectedOrder: TravelAlbumAvailableOrder?
private let scrollView = UIScrollView()
private let contentView = UIView()
private let titleLabel = UILabel()
private let preShootCard = TravelAlbumModeOptionView()
private let preOrderCard = TravelAlbumModeOptionView()
private let fieldsStack = UIStackView()
private let freeCountField = UITextField()
private let singlePriceField = UITextField()
private let packagePriceField = UITextField()
private let orderButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
sheetPresentationController.detents = [.medium(), .large()]
sheetPresentationController.prefersGrabberVisible = false
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {}
override func setupUI() {
view.backgroundColor = .white
titleLabel.text = "新建相册任务"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
preShootCard.apply(
title: "先拍再买",
desc: "拍完后分享给用户,用户在小程序上选择性购买",
selected: true
)
preOrderCard.apply(
title: "买了再拍",
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
selected: false
)
fieldsStack.axis = .vertical
fieldsStack.spacing = 14
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
orderButton.contentHorizontalAlignment = .left
orderButton.setTitle("请选择订单", for: .normal)
orderButton.setTitleColor(AppColor.textTertiary, for: .normal)
orderButton.titleLabel?.font = .systemFont(ofSize: 14)
orderButton.layer.cornerRadius = 8
orderButton.layer.borderColor = AppColor.border.cgColor
orderButton.layer.borderWidth = 1
orderButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView)
scrollView.addSubview(contentView)
[titleLabel, preShootCard, preOrderCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview)
rebuildFields()
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
contentView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(18)
make.leading.trailing.equalToSuperview().inset(20)
}
preShootCard.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16)
}
preOrderCard.snp.makeConstraints { make in
make.top.equalTo(preShootCard.snp.bottom).offset(10)
make.leading.trailing.equalTo(preShootCard)
}
fieldsStack.snp.makeConstraints { make in
make.top.equalTo(preOrderCard.snp.bottom).offset(18)
make.leading.trailing.equalTo(preShootCard)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(fieldsStack.snp.bottom).offset(22)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(48)
make.width.equalTo(confirmButton)
make.bottom.equalToSuperview().offset(-16)
}
confirmButton.snp.makeConstraints { make in
make.top.height.width.equalTo(cancelButton)
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
}
}
override func bindActions() {
preShootCard.addTarget(self, action: #selector(preShootTapped), for: .touchUpInside)
preOrderCard.addTarget(self, action: #selector(preOrderTapped), for: .touchUpInside)
orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
[freeCountField, singlePriceField, packagePriceField].forEach {
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed {
onDismissed?()
}
}
private func configureTextField(_ field: UITextField, placeholder: String, keyboardType: UIKeyboardType) {
field.placeholder = placeholder
field.keyboardType = keyboardType
field.font = .systemFont(ofSize: 14)
field.layer.cornerRadius = 8
field.layer.borderWidth = 1
field.layer.borderColor = AppColor.border.cgColor
field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
field.leftViewMode = .always
field.snp.makeConstraints { make in
make.height.equalTo(44)
}
}
private func configureActionButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(titleColor, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = backgroundColor
button.layer.cornerRadius = 10
}
private func rebuildFields() {
fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if mode == .preShoot {
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
} else {
fieldsStack.addArrangedSubview(makeOrderGroup())
}
}
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
let container = UIView()
let label = UILabel()
label.attributedText = fieldTitle(title, required: required)
container.addSubview(label)
container.addSubview(field)
label.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
field.snp.makeConstraints { make in
make.top.equalTo(label.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
return container
}
private func makeOrderGroup() -> UIView {
let container = UIView()
let label = UILabel()
label.attributedText = fieldTitle("绑定订单", required: false)
container.addSubview(label)
container.addSubview(orderButton)
label.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
orderButton.snp.makeConstraints { make in
make.top.equalTo(label.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.greaterThanOrEqualTo(46)
}
return container
}
private func fieldTitle(_ text: String, required: Bool) -> NSAttributedString {
let result = NSMutableAttributedString(
string: text,
attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium), .foregroundColor: AppColor.textPrimary]
)
if required {
result.append(NSAttributedString(string: " *", attributes: [.foregroundColor: UIColor.red]))
}
return result
}
private func updateMode(_ newMode: TravelAlbumEntryViewModel.CreateMode) {
mode = newMode
preShootCard.apply(
title: "先拍再买",
desc: "拍完后分享给用户,用户在小程序上选择性购买",
selected: mode == .preShoot
)
preOrderCard.apply(
title: "买了再拍",
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
selected: mode == .preOrder
)
rebuildFields()
}
private func updateOrderButton() {
guard let order = selectedOrder else {
orderButton.setTitle("请选择订单", for: .normal)
orderButton.setTitleColor(AppColor.textTertiary, for: .normal)
return
}
let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName)\n手机号:\(order.userPhone)\n订单号:\(order.orderNumber)"
orderButton.setTitle(title, for: .normal)
orderButton.setTitleColor(AppColor.textPrimary, for: .normal)
orderButton.titleLabel?.numberOfLines = 3
}
@objc private func preShootTapped() {
updateMode(.preShoot)
}
@objc private func preOrderTapped() {
updateMode(.preOrder)
}
@objc private func orderTapped() {
let alert = UIAlertController(title: "绑定订单", message: nil, preferredStyle: .actionSheet)
if viewModel.availableOrders.isEmpty {
alert.addAction(UIAlertAction(title: "暂无可绑定订单", style: .default))
} else {
viewModel.availableOrders.forEach { order in
let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName) \(order.userPhone)"
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
self?.selectedOrder = order
self?.updateOrderButton()
})
}
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
@objc private func confirmTapped() {
Task {
await viewModel.confirmCreate(
mode: mode,
freeCount: freeCountField.text ?? "",
singlePrice: singlePriceField.text ?? "",
packagePrice: packagePriceField.text ?? "",
order: selectedOrder,
api: api
)
await MainActor.run {
if !viewModel.isCreateSheetVisible {
dismiss(animated: true)
}
}
}
}
@objc private func textFieldEditingChanged(_ field: UITextField) {
let text = field.text ?? ""
if field === freeCountField {
field.text = text.filter(\.isNumber)
} else {
field.text = sanitizeMoneyInput(text)
}
}
private func sanitizeMoneyInput(_ value: String) -> String {
var result = ""
var hasDot = false
for char in value {
if char.isNumber {
result.append(char)
} else if char == ".", !hasDot {
result.append(char)
hasDot = true
}
}
guard !result.isEmpty else { return "" }
if result.hasPrefix(".") {
result = "0" + result
}
guard let dotIndex = result.firstIndex(of: ".") else {
let trimmed = result.drop { $0 == "0" }
return trimmed.isEmpty ? "0" : String(trimmed)
}
let integerPart = result[..<dotIndex].drop { $0 == "0" }
let decimalStart = result.index(after: dotIndex)
let decimal = result[decimalStart...].prefix(2)
return "\(integerPart.isEmpty ? "0" : String(integerPart)).\(decimal)"
}
}
///
private final class TravelAlbumModeOptionView: UIControl {
private let dotView = UIView()
private let innerDotView = UIView()
private let titleLabel = UILabel()
private let descLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 10
layer.borderWidth = 1
dotView.layer.cornerRadius = 10
innerDotView.backgroundColor = .white
innerDotView.layer.cornerRadius = 3.5
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
descLabel.font = .systemFont(ofSize: 12)
descLabel.textColor = AppColor.textSecondary
descLabel.numberOfLines = 0
addSubview(dotView)
dotView.addSubview(innerDotView)
addSubview(titleLabel)
addSubview(descLabel)
dotView.snp.makeConstraints { make in
make.leading.top.equalToSuperview().offset(14)
make.size.equalTo(20)
}
innerDotView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(7)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.equalTo(dotView.snp.trailing).offset(10)
make.trailing.equalToSuperview().offset(-14)
}
descLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(titleLabel)
make.bottom.equalToSuperview().offset(-14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, desc: String, selected: Bool) {
titleLabel.text = title
descLabel.text = desc
backgroundColor = selected ? AppColor.primary.withAlphaComponent(0.06) : .white
layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
dotView.backgroundColor = selected ? AppColor.primary : .clear
dotView.layer.borderWidth = selected ? 0 : 2
dotView.layer.borderColor = AppColor.border.cgColor
innerDotView.isHidden = !selected
}
}

View File

@ -0,0 +1,162 @@
//
// TravelAlbumCodeDialogViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `TravelAlbumCodeDialog`
final class TravelAlbumCodeDialogViewController: UIViewController {
var onClose: (() -> Void)?
var onDownload: (() -> Void)?
private let state: TravelAlbumEntryViewModel.AlbumCodeState
private let dimView = UIView()
private let cardView = UIView()
private let titleLabel = UILabel()
private let closeButton = UIButton(type: .system)
private let albumNameLabel = UILabel()
private let qrImageView = UIImageView()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let hintLabel = UILabel()
private let downloadButton = UIButton(type: .system)
init(state: TravelAlbumEntryViewModel.AlbumCodeState) {
self.state = state
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
applyState()
}
private func setupUI() {
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.clipsToBounds = true
titleLabel.text = "相册码"
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
closeButton.tintColor = AppColor.textSecondary
albumNameLabel.font = .systemFont(ofSize: 18, weight: .medium)
albumNameLabel.textColor = AppColor.textPrimary
albumNameLabel.textAlignment = .center
qrImageView.contentMode = .scaleAspectFit
qrImageView.layer.cornerRadius = 8
qrImageView.clipsToBounds = true
hintLabel.textAlignment = .center
hintLabel.font = .systemFont(ofSize: 13)
hintLabel.textColor = AppColor.textSecondary
hintLabel.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
hintLabel.layer.cornerRadius = 8
hintLabel.layer.borderWidth = 1
hintLabel.layer.borderColor = AppColor.primary.withAlphaComponent(0.35).cgColor
hintLabel.clipsToBounds = true
let hint = NSMutableAttributedString(string: "微信", attributes: [.foregroundColor: AppColor.textSecondary])
hint.append(NSAttributedString(string: "扫一扫", attributes: [.foregroundColor: AppColor.primary, .font: UIFont.systemFont(ofSize: 13, weight: .medium)]))
hint.append(NSAttributedString(string: ",查看下载照片", attributes: [.foregroundColor: AppColor.textSecondary]))
hintLabel.attributedText = hint
downloadButton.setTitle("下载图片", for: .normal)
downloadButton.setTitleColor(.white, for: .normal)
downloadButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
downloadButton.backgroundColor = AppColor.primary
downloadButton.layer.cornerRadius = 23
view.addSubview(dimView)
view.addSubview(cardView)
[titleLabel, closeButton, albumNameLabel, qrImageView, loadingIndicator, hintLabel, downloadButton].forEach(cardView.addSubview)
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
downloadButton.addTarget(self, action: #selector(downloadTapped), for: .touchUpInside)
}
private func setupConstraints() {
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.equalToSuperview().multipliedBy(0.78)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(18)
make.leading.trailing.equalToSuperview().inset(48)
}
closeButton.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-20)
make.size.equalTo(28)
}
albumNameLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(20)
}
qrImageView.snp.makeConstraints { make in
make.top.equalTo(albumNameLabel.snp.bottom).offset(18)
make.centerX.equalToSuperview()
make.size.equalTo(190)
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalTo(qrImageView)
}
hintLabel.snp.makeConstraints { make in
make.top.equalTo(qrImageView.snp.bottom).offset(18)
make.centerX.equalToSuperview()
make.height.equalTo(34)
make.leading.greaterThanOrEqualToSuperview().offset(20)
}
downloadButton.snp.makeConstraints { make in
make.top.equalTo(hintLabel.snp.bottom).offset(20)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(46)
make.bottom.equalToSuperview().offset(-18)
}
}
private func applyState() {
albumNameLabel.text = state.albumName.isEmpty ? "未命名相册" : state.albumName
if state.isLoading {
loadingIndicator.startAnimating()
qrImageView.image = nil
} else {
loadingIndicator.stopAnimating()
if let url = URL(string: state.qrImageUrl), !state.qrImageUrl.isEmpty {
qrImageView.kf.setImage(with: url)
} else {
qrImageView.image = UIImage(systemName: "qrcode")
qrImageView.tintColor = AppColor.textSecondary
}
}
downloadButton.isEnabled = !state.isLoading && !state.qrImageUrl.isEmpty
downloadButton.alpha = downloadButton.isEnabled ? 1 : 0.5
}
func updateDownloading(_ isDownloading: Bool) {
downloadButton.setTitle(isDownloading ? "保存中..." : "下载图片", for: .normal)
downloadButton.isEnabled = !isDownloading
}
@objc private func closeTapped() {
onClose?()
dismiss(animated: true)
}
@objc private func downloadTapped() {
onDownload?()
}
}

View File

@ -0,0 +1,479 @@
//
// TravelAlbumDetailViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `TravelAlbumDetailScreen`
final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing
private let scrollContainer = UIView()
private let infoCard = TravelAlbumInfoCard()
private let manageCard = UIView()
private let tabStack = UIStackView()
private let allTabButton = UIButton(type: .system)
private let purchasedTabButton = UIButton(type: .system)
private let sortButton = UIButton(type: .system)
private let selectButton = UIButton(type: .system)
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
private let bottomBar = UIView()
private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system)
init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "相册管理"
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.confirmDeleteAlbum()
},
])
)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
manageCard.backgroundColor = .white
manageCard.layer.cornerRadius = 12
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
manageCard.layer.shadowOpacity = 1
manageCard.layer.shadowRadius = 6
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
tabStack.axis = .horizontal
tabStack.spacing = 8
configurePillButton(allTabButton)
configurePillButton(purchasedTabButton)
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill"))
configureIconButton(selectButton, image: UIImage(systemName: "circle"))
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white
collectionView.delegate = self
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
[weak self] collectionView, indexPath, material in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumMaterialCell
cell.apply(
material: material,
selectionMode: self?.viewModel.isSelectionMode == true,
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true
)
return cell
}
bottomBar.backgroundColor = AppColor.pageBackground
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
deleteSelectedButton.isHidden = true
view.addSubview(scrollContainer)
scrollContainer.addSubview(infoCard)
scrollContainer.addSubview(manageCard)
manageCard.addSubview(tabStack)
tabStack.addArrangedSubview(allTabButton)
tabStack.addArrangedSubview(purchasedTabButton)
manageCard.addSubview(sortButton)
manageCard.addSubview(selectButton)
manageCard.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(deleteSelectedButton)
bottomBar.addSubview(uploadButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
deleteSelectedButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(48)
}
uploadButton.snp.makeConstraints { make in
make.top.equalTo(deleteSelectedButton.snp.bottom).offset(8)
make.leading.trailing.equalTo(deleteSelectedButton)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
scrollContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
}
infoCard.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.greaterThanOrEqualTo(76)
}
manageCard.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview()
}
tabStack.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12)
make.height.equalTo(32)
}
sortButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
make.size.equalTo(32)
}
selectButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.trailing.equalToSuperview().offset(-12)
make.size.equalTo(32)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
}
}
override func bindActions() {
infoCard.onCall = { [weak self] phone in self?.call(phone) }
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onDeletedAlbum = { [weak self] _ in
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refreshAll(api: api) }
}
@MainActor
private func applyViewModel() {
if let album = viewModel.album {
infoCard.apply(album: album)
}
configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased)
selectButton.isHidden = viewModel.selectedTab != .all
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
deleteSelectedButton.isHidden = !(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.materials)
dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.isLoading && viewModel.album == nil {
showLoading()
} else {
hideLoading()
}
}
private func configurePillButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16
button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14)
}
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
button.setTitle(title, for: .normal)
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF)
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal)
}
private func configureIconButton(_ button: UIButton, image: UIImage?) {
button.setImage(image, for: .normal)
button.backgroundColor = UIColor(hex: 0xEAF4FF)
button.tintColor = AppColor.primary
button.layer.cornerRadius = 16
}
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = color
button.layer.cornerRadius = 10
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let spacing: CGFloat = 8
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 38))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 12
return section
}
}
private func call(_ phone: String) {
guard !phone.isEmpty, let url = URL(string: "tel:\(phone)") else { return }
UIApplication.shared.open(url)
}
private func confirmDeleteAlbum() {
let alert = UIAlertController(title: "删除相册", message: "确定删除该相册吗?已有购买素材的相册无法删除。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.deleteAlbum(api: self.api) }
})
present(alert, animated: true)
}
@objc private func allTabTapped() {
Task { await viewModel.selectTab(.all, api: api) }
}
@objc private func purchasedTabTapped() {
Task { await viewModel.selectTab(.purchased, api: api) }
}
@objc private func sortTapped() {
let alert = UIAlertController(title: "排序", message: nil, preferredStyle: .actionSheet)
TravelAlbumDetailViewModel.SortOption.allCases.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setSortOption(option, api: self.api) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func selectTapped() {
viewModel.toggleSelectionMode()
}
@objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.deleteSelectedMaterials(api: self.api) }
})
present(alert, animated: true)
}
@objc private func uploadTapped() {
let album = viewModel.album
let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "",
headerPhone: album?.displayPhone ?? ""
)
)
navigationController?.pushViewController(controller, animated: true)
}
}
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
viewModel.toggleMaterialSelection(material)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let visible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? 0
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: visible, api: api) }
}
}
///
private final class TravelAlbumInfoCard: UIView {
var onCall: ((String) -> Void)?
private var phone = ""
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
private let phoneLabel = UILabel()
private let timeLabel = UILabel()
private let callButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
layer.shadowOpacity = 1
layer.shadowRadius = 6
layer.shadowOffset = CGSize(width: 0, height: 2)
let iconBox = UIView()
iconBox.backgroundColor = AppColor.primary
iconBox.layer.cornerRadius = 10
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
phoneLabel.font = .systemFont(ofSize: 14, weight: .medium)
phoneLabel.textColor = AppColor.textPrimary
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = AppColor.textTertiary
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary
callButton.backgroundColor = UIColor(hex: 0xEAF4FF)
callButton.layer.cornerRadius = 20
addSubview(iconBox)
iconBox.addSubview(iconView)
addSubview(phoneLabel)
addSubview(timeLabel)
addSubview(callButton)
iconBox.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14)
make.size.equalTo(48)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(iconBox.snp.trailing).offset(12)
make.trailing.equalTo(callButton.snp.leading).offset(-12)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(phoneLabel)
make.bottom.equalToSuperview().offset(-16)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(40)
}
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(album: TravelAlbum) {
phone = album.displayPhone
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))"
timeLabel.text = "创建时间 \(album.createdAt)"
}
@objc private func callTapped() {
onCall?(phone)
}
}
/// cell
private final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView()
private let statusLabel = UILabel()
private let checkImageView = UIImageView()
private let nameLabel = UILabel()
private let sizeLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 8
statusLabel.text = "已上传"
statusLabel.font = .systemFont(ofSize: 10)
statusLabel.textColor = .white
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
checkImageView.tintColor = .white
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 10
nameLabel.font = .systemFont(ofSize: 11)
nameLabel.textColor = AppColor.textPrimary
nameLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary
contentView.addSubview(imageView)
imageView.addSubview(statusLabel)
imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel)
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width)
}
statusLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(4)
make.height.equalTo(18)
make.width.equalTo(48)
}
checkImageView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(20)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview()
}
sizeLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
if let url = URL(string: urlString), !urlString.isEmpty {
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.primary
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
}
checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white
nameLabel.text = material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
}
}

View File

@ -0,0 +1,456 @@
//
// TravelAlbumEntryViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `TravelAlbumEntryScreen`
final class TravelAlbumEntryViewController: BaseViewController {
private let viewModel = TravelAlbumEntryViewModel()
private let api: any TravelAlbumServing
private let heroCard = TravelAlbumHeroCard()
private let titleLabel = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyView = TravelAlbumEmptyView()
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "新增相册"
navigationController?.setNavigationBarHidden(false, animated: false)
}
override func setupUI() {
view.backgroundColor = .white
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = .black
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 132
tableView.delegate = self
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Int, TravelAlbum>(tableView: tableView) {
[weak self] tableView, indexPath, album in
let cell = tableView.dequeueReusableCell(
withIdentifier: TravelAlbumTaskCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumTaskCell
cell.apply(album: album)
cell.onShootUpload = { [weak self] in self?.pushWiredTransfer(album: album) }
cell.onAlbumCode = { [weak self] in self?.loadAlbumCode(album) }
cell.onShare = { [weak self] in self?.showShareSheet() }
return cell
}
view.addSubview(heroCard)
view.addSubview(titleLabel)
view.addSubview(tableView)
view.addSubview(emptyView)
}
override func setupConstraints() {
heroCard.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(108)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(heroCard.snp.bottom).offset(28)
make.leading.trailing.equalToSuperview().inset(20)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
emptyView.snp.makeConstraints { make in
make.top.equalTo(heroCard.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadAlbums(api: api) }
}
@MainActor
private func applyViewModel() {
heroCard.isLoading = viewModel.isCreating
titleLabel.text = "我的任务(\(viewModel.albumTotal)"
titleLabel.isHidden = viewModel.albums.isEmpty
tableView.isHidden = viewModel.albums.isEmpty
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.albums)
dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.isLoading && viewModel.albums.isEmpty {
showLoading()
} else {
hideLoading()
}
}
@objc private func createTapped() {
Task {
await viewModel.openCreateSheet(api: api)
await MainActor.run {
guard viewModel.isCreateSheetVisible, presentedViewController == nil else { return }
let sheet = CreateTravelAlbumSheetViewController(viewModel: viewModel, api: api)
sheet.onDismissed = { [weak self] in self?.viewModel.dismissCreateSheet() }
present(sheet, animated: true)
}
}
}
private func loadAlbumCode(_ album: TravelAlbum) {
Task {
await viewModel.openAlbumCode(album: album, api: api)
await MainActor.run { self.presentAlbumCodeIfNeeded() }
}
}
@MainActor
private func presentAlbumCodeIfNeeded() {
guard let state = viewModel.albumCodeState else { return }
let controller = TravelAlbumCodeDialogViewController(state: state)
controller.onClose = { [weak self] in self?.viewModel.dismissAlbumCode() }
controller.onDownload = { [weak self, weak controller] in
guard let self, let url = self.viewModel.albumCodeState?.qrImageUrl, !url.isEmpty else { return }
self.viewModel.setDownloadingQRCode(true)
controller?.updateDownloading(true)
Task {
do {
try await ScenicQueueQRCodeSaver.saveImage(from: url)
await MainActor.run { self.showToast("已保存到相册") }
} catch {
await MainActor.run { self.showToast(error.localizedDescription) }
}
await MainActor.run {
self.viewModel.setDownloadingQRCode(false)
controller?.updateDownloading(false)
}
}
}
present(controller, animated: true)
}
private func showShareSheet() {
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "微信好友", style: .default) { [weak self] _ in
self?.showToast("已选择微信好友")
})
sheet.addAction(UIAlertAction(title: "QQ好友", style: .default) { [weak self] _ in
self?.showToast("已选择QQ好友")
})
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
private func pushWiredTransfer(album: TravelAlbum) {
let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel(
albumId: album.id,
albumTitle: album.name,
headerPhone: album.displayPhone
)
)
navigationController?.pushViewController(controller, animated: true)
}
}
extension TravelAlbumEntryViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let album = dataSource.itemIdentifier(for: indexPath) else { return }
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: album.id), animated: true)
}
}
///
private final class TravelAlbumHeroCard: UIControl {
private let gradientLayer = CAGradientLayer()
private let plusView = UIImageView(image: UIImage(systemName: "plus"))
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let arrowLabel = UILabel()
var isLoading = false {
didSet { titleLabel.text = isLoading ? "新建中..." : "新建相册任务" }
}
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 10
clipsToBounds = true
gradientLayer.colors = [UIColor(hex: 0x007BFF).cgColor, UIColor(hex: 0x3A91FF).cgColor]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
layer.insertSublayer(gradientLayer, at: 0)
plusView.tintColor = AppColor.primary
plusView.contentMode = .scaleAspectFit
plusView.backgroundColor = .white
plusView.layer.cornerRadius = 17
titleLabel.text = "新建相册任务"
titleLabel.font = .systemFont(ofSize: 19, weight: .medium)
titleLabel.textColor = .white
subtitleLabel.text = "创建一个新的相册拍摄任务"
subtitleLabel.font = .systemFont(ofSize: 14)
subtitleLabel.textColor = .white
arrowLabel.text = ""
arrowLabel.textColor = .white
arrowLabel.font = .systemFont(ofSize: 36, weight: .light)
addSubview(plusView)
addSubview(titleLabel)
addSubview(subtitleLabel)
addSubview(arrowLabel)
plusView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(28)
make.centerY.equalToSuperview()
make.size.equalTo(34)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(30)
make.leading.equalTo(plusView.snp.trailing).offset(16)
make.trailing.lessThanOrEqualTo(arrowLabel.snp.leading).offset(-8)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.equalTo(titleLabel)
make.trailing.lessThanOrEqualTo(arrowLabel.snp.leading).offset(-8)
}
arrowLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-18)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
}
}
/// cell
private final class TravelAlbumTaskCell: UITableViewCell {
static let reuseIdentifier = "TravelAlbumTaskCell"
var onShootUpload: (() -> Void)?
var onAlbumCode: (() -> Void)?
var onShare: (() -> Void)?
private let cardView = UIView()
private let coverView = UIImageView()
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
private let nameLabel = UILabel()
private let timeLabel = UILabel()
private let phoneLabel = UILabel()
private let shareButton = UIButton(type: .system)
private let codeButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 10
cardView.layer.shadowColor = UIColor(hex: 0x2F6BFF).withAlphaComponent(0.10).cgColor
cardView.layer.shadowOpacity = 1
cardView.layer.shadowRadius = 10
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
coverView.backgroundColor = UIColor(hex: 0xEAF2FF)
coverView.layer.cornerRadius = 8
coverView.clipsToBounds = true
coverView.contentMode = .scaleAspectFill
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
nameLabel.font = .systemFont(ofSize: 16, weight: .bold)
nameLabel.textColor = AppColor.textPrimary
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = AppColor.textTertiary
phoneLabel.font = .systemFont(ofSize: 12)
phoneLabel.textColor = AppColor.textTertiary
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
shareButton.tintColor = AppColor.primary
codeButton.setImage(UIImage(systemName: "qrcode"), for: .normal)
codeButton.tintColor = .black
uploadButton.setImage(UIImage(systemName: "camera.fill"), for: .normal)
uploadButton.setTitle(" 拍摄上传", for: .normal)
uploadButton.tintColor = AppColor.primary
uploadButton.setTitleColor(AppColor.primary, for: .normal)
uploadButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .bold)
uploadButton.backgroundColor = UIColor(hex: 0xEAF4FF)
uploadButton.layer.cornerRadius = 8
contentView.addSubview(cardView)
cardView.addSubview(coverView)
coverView.addSubview(iconView)
cardView.addSubview(nameLabel)
cardView.addSubview(timeLabel)
cardView.addSubview(phoneLabel)
cardView.addSubview(shareButton)
cardView.addSubview(codeButton)
cardView.addSubview(uploadButton)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20))
}
coverView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(10)
make.size.equalTo(86)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(42)
}
shareButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.trailing.equalTo(codeButton.snp.leading).offset(-8)
make.size.equalTo(26)
}
codeButton.snp.makeConstraints { make in
make.top.equalTo(shareButton)
make.trailing.equalToSuperview().offset(-10)
make.size.equalTo(26)
}
nameLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.equalTo(coverView.snp.trailing).offset(12)
make.trailing.equalTo(shareButton.snp.leading).offset(-8)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(7)
make.leading.trailing.equalTo(nameLabel)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(5)
make.leading.trailing.equalTo(nameLabel)
}
uploadButton.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(9)
make.trailing.equalToSuperview().offset(-10)
make.bottom.equalToSuperview().offset(-10)
make.height.equalTo(34)
make.width.greaterThanOrEqualTo(104)
}
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
codeButton.addTarget(self, action: #selector(codeTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(album: TravelAlbum) {
nameLabel.text = album.name.isEmpty ? "未命名任务" : album.name
timeLabel.text = "创建时间:\(TravelAlbumDisplayFormatter.shortTaskTime(album.createdAt))"
phoneLabel.text = "手机号:\(album.displayPhone.isEmpty ? "--" : album.displayPhone)"
if let url = URL(string: album.coverUrl), !album.coverUrl.isEmpty {
iconView.isHidden = true
coverView.kf.setImage(with: url)
} else {
coverView.image = nil
iconView.isHidden = false
}
}
@objc private func uploadTapped() { onShootUpload?() }
@objc private func shareTapped() { onShare?() }
@objc private func codeTapped() { onAlbumCode?() }
}
///
private final class TravelAlbumEmptyView: UIView {
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
isHidden = true
imageView.tintColor = AppColor.primary
imageView.contentMode = .scaleAspectFit
titleLabel.text = "暂无任务"
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = .black
titleLabel.textAlignment = .center
subtitleLabel.text = "点击“新建相册任务”开始创建任务"
subtitleLabel.font = .systemFont(ofSize: 14)
subtitleLabel.textColor = AppColor.textTertiary
subtitleLabel.textAlignment = .center
let backdrop = UIView()
backdrop.backgroundColor = UIColor(hex: 0xF2F7FF)
backdrop.layer.cornerRadius = 54
addSubview(backdrop)
addSubview(imageView)
addSubview(titleLabel)
addSubview(subtitleLabel)
backdrop.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-70)
make.width.equalToSuperview().multipliedBy(0.58)
make.height.equalTo(108)
}
imageView.snp.makeConstraints { make in
make.center.equalTo(backdrop)
make.size.equalTo(82)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(backdrop.snp.bottom).offset(22)
make.leading.trailing.equalToSuperview().inset(20)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(10)
make.leading.trailing.equalTo(titleLabel)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,311 @@
//
// WiredCameraTransferViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 线 Android UI OTG
final class WiredCameraTransferViewController: BaseViewController {
private let viewModel: WiredCameraTransferViewModel
private let headerView = UIView()
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let phoneLabel = UILabel()
private let statusCard = UIView()
private let storagePanel = UIView()
private let storageTitleLabel = UILabel()
private let storageValueLabel = UILabel()
private let statusLabel = UILabel()
private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel()
private let chipsStack = UIStackView()
private let statsCard = UIStackView()
private let emptyLabel = UILabel()
private let bottomBar = UIView()
private let batchButton = UIButton(type: .system)
private let specifyButton = UIButton(type: .system)
init(viewModel: WiredCameraTransferViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
navigationController?.setNavigationBarHidden(true, animated: false)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
headerView.backgroundColor = AppColor.primary
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = .white
titleLabel.text = viewModel.albumTitle
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = .white
phoneLabel.text = viewModel.headerPhone
phoneLabel.font = .systemFont(ofSize: 13)
phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92)
statusCard.backgroundColor = .white
statusCard.layer.cornerRadius = 12
statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor
statusCard.layer.shadowOpacity = 1
statusCard.layer.shadowRadius = 8
statusCard.layer.shadowOffset = CGSize(width: 0, height: 3)
storagePanel.layer.cornerRadius = 12
storagePanel.layer.borderColor = AppColor.border.cgColor
storagePanel.layer.borderWidth = 3
storageTitleLabel.text = viewModel.deviceModelName
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
storageTitleLabel.textColor = AppColor.textPrimary
storageTitleLabel.textAlignment = .center
storageValueLabel.text = viewModel.availableStorageText
storageValueLabel.font = .systemFont(ofSize: 10)
storageValueLabel.textColor = AppColor.primary
storageValueLabel.textAlignment = .center
statusLabel.text = viewModel.cameraStatusText
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
statusLabel.textColor = AppColor.danger
statusLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.10)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
refreshButton.setTitleColor(.white, for: .normal)
refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
refreshButton.backgroundColor = AppColor.danger
refreshButton.layer.cornerRadius = 8
helpLabel.attributedText = helpText()
helpLabel.font = .systemFont(ofSize: 10)
helpLabel.numberOfLines = 2
chipsStack.axis = .horizontal
chipsStack.spacing = 6
[viewModel.retouchOption, viewModel.photoFormatOption, viewModel.transferModeOption].forEach {
chipsStack.addArrangedSubview(makeChip($0))
}
statsCard.axis = .horizontal
statsCard.distribution = .fillEqually
["全部", "已上传", "失败"].enumerated().forEach { index, title in
statsCard.addArrangedSubview(makeStat(title: title, count: viewModel.tabCounts[index], selected: index == 0, danger: index == 2))
}
emptyLabel.text = "暂无照片"
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center
bottomBar.backgroundColor = .white
configureBottomButton(batchButton, title: "批量上传", filled: true)
configureBottomButton(specifyButton, title: "指定上传", filled: false)
view.addSubview(headerView)
headerView.addSubview(backButton)
headerView.addSubview(titleLabel)
headerView.addSubview(phoneLabel)
view.addSubview(statusCard)
statusCard.addSubview(storagePanel)
storagePanel.addSubview(storageTitleLabel)
storagePanel.addSubview(storageValueLabel)
statusCard.addSubview(statusLabel)
statusCard.addSubview(refreshButton)
statusCard.addSubview(helpLabel)
statusCard.addSubview(chipsStack)
statusCard.addSubview(statsCard)
view.addSubview(emptyLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(batchButton)
bottomBar.addSubview(specifyButton)
}
override func setupConstraints() {
headerView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(118)
}
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(8)
make.bottom.equalToSuperview().offset(-28)
make.size.equalTo(44)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(backButton.snp.trailing).offset(4)
make.trailing.equalToSuperview().offset(-16)
make.top.equalTo(backButton).offset(4)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
statusCard.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom).offset(-10)
make.leading.trailing.equalToSuperview().inset(16)
}
storagePanel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(14)
make.width.greaterThanOrEqualTo(76)
make.height.equalTo(76)
}
storageTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(8)
}
storageValueLabel.snp.makeConstraints { make in
make.top.equalTo(storageTitleLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(storageTitleLabel)
}
statusLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(storagePanel.snp.trailing).offset(10)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(84)
}
refreshButton.snp.makeConstraints { make in
make.centerY.equalTo(statusLabel)
make.trailing.equalToSuperview().offset(-14)
make.height.equalTo(34)
make.width.greaterThanOrEqualTo(72)
}
helpLabel.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(4)
make.leading.equalTo(statusLabel)
make.trailing.equalTo(refreshButton)
}
chipsStack.snp.makeConstraints { make in
make.top.equalTo(storagePanel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(14)
make.height.equalTo(34)
}
statsCard.snp.makeConstraints { make in
make.top.equalTo(chipsStack.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(68)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
batchButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(44)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
specifyButton.snp.makeConstraints { make in
make.top.height.width.equalTo(batchButton)
make.leading.equalTo(batchButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
}
emptyLabel.snp.makeConstraints { make in
make.top.equalTo(statusCard.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
}
override func bindActions() {
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
specifyButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
}
private func makeChip(_ text: String) -> UIView {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 11)
label.textColor = AppColor.textTertiary
label.backgroundColor = AppColor.pageBackground
label.layer.cornerRadius = 8
label.layer.borderWidth = 1
label.layer.borderColor = AppColor.border.cgColor
label.clipsToBounds = true
label.textAlignment = .center
return label
}
private func makeStat(title: String, count: Int, selected: Bool, danger: Bool) -> UIView {
let container = UIView()
let countLabel = UILabel()
let titleLabel = UILabel()
let underline = UIView()
countLabel.text = "\(count)"
countLabel.font = .systemFont(ofSize: 18, weight: .semibold)
countLabel.textColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
countLabel.textAlignment = .center
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 13)
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
titleLabel.textAlignment = .center
underline.backgroundColor = selected ? AppColor.primary : .clear
underline.layer.cornerRadius = 1
container.addSubview(countLabel)
container.addSubview(titleLabel)
container.addSubview(underline)
countLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(countLabel.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview()
}
underline.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.centerX.equalToSuperview()
make.width.equalTo(24)
make.height.equalTo(2)
}
return container
}
private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 15)
button.layer.cornerRadius = 10
if filled {
button.backgroundColor = AppColor.primary
button.setTitleColor(.white, for: .normal)
} else {
button.backgroundColor = .white
button.setTitleColor(AppColor.primary, for: .normal)
button.layer.borderWidth = 1
button.layer.borderColor = AppColor.primary.cgColor
}
}
private func helpText() -> NSAttributedString {
let text = NSMutableAttributedString(
string: "未连接,查看",
attributes: [.foregroundColor: AppColor.textSecondary]
)
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
return text
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func uiOnlyTapped() {
viewModel.showUIOnlyMessage()
}
}

View File

@ -0,0 +1,81 @@
//
// TravelAlbumAPITests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
/// API
final class TravelAlbumAPITests: XCTestCase {
func testListBuildsPathAndQuery() async throws {
let data = envelopeJSON(#"{"total":1,"list":[]}"#)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.list(page: 2, pageSize: 30)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/list")
let query = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems)
XCTAssertEqual(query.first { $0.name == "page" }?.value, "2")
XCTAssertEqual(query.first { $0.name == "page_size" }?.value, "30")
}
func testCreateEncodesBody() async throws {
let data = envelopeJSON(#"{"id":8}"#)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.create(
TravelAlbumCreateRequest(
name: "2026-07-07-001",
type: 1,
orderNumber: nil,
materialNum: 2,
materialPrice: 10.5,
materialPackagePrice: 88,
photoPrice: 0
)
)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/create")
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
XCTAssertEqual(body?["name"] as? String, "2026-07-07-001")
XCTAssertEqual(body?["type"] as? Int, 1)
XCTAssertEqual(body?["material_num"] as? Int, 2)
XCTAssertEqual(body?["material_price"] as? Double, 10.5)
}
func testMaterialListAndDeleteAndMpCode() async throws {
let materialList = envelopeJSON(#"{"total":0,"list":[]}"#)
let empty = envelopeJSON("{}")
let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
let session = MockURLSession(responses: [materialList, empty, code])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.materialList(userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1)
try await api.deleteAlbum(id: 3)
let response = try await api.mpCode(id: 3)
XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png")
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list")
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
XCTAssertEqual(query?.first { $0.name == "order_by" }?.value, "4")
XCTAssertEqual(query?.first { $0.name == "is_purchased" }?.value, "1")
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/travel-album/delete")
let body = try JSONSerialization.jsonObject(with: session.requests[1].httpBody!) as? [String: Any]
XCTAssertEqual(body?["id"] as? Int, 3)
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}
""".data(using: .utf8)!
}
}

View File

@ -0,0 +1,92 @@
//
// TravelAlbumModelsTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
///
final class TravelAlbumModelsTests: XCTestCase {
func testTravelAlbumDecodesSnakeCaseFields() throws {
let json = """
{
"id": 7,
"store_user_id": 2,
"name": "",
"type": 1,
"order_number": "NO001",
"material_num": 3,
"material_price": 1200,
"material_package_price": 5000,
"photo_price": 0,
"cover_url": "https://cdn/cover.jpg",
"user_id": 9,
"status": 1,
"created_at": "2026-07-07 10:00:00",
"updated_at": "2026-07-07 10:10:00",
"user": {"id": 4, "phone": "13800138000"}
}
""".data(using: .utf8)!
let album = try JSONDecoder().decode(TravelAlbum.self, from: json)
XCTAssertEqual(album.id, 7)
XCTAssertEqual(album.storeUserId, 2)
XCTAssertEqual(album.orderNumber, "NO001")
XCTAssertEqual(album.displayPhone, "13800138000")
}
func testTravelAlbumMaterialDecodesSnakeCaseFields() throws {
let json = """
{
"id": 11,
"user_equity_travel_id": 7,
"status": 1,
"order_number": "",
"user_id": 9,
"file_name": "IMG_0001.JPG",
"file_type": 2,
"file_url": "https://cdn/a.jpg",
"file_size": 2048,
"cover_url": "",
"is_purchased": false,
"created_at": "",
"updated_at": ""
}
""".data(using: .utf8)!
let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json)
XCTAssertEqual(material.userEquityTravelId, 7)
XCTAssertEqual(material.fileName, "IMG_0001.JPG")
XCTAssertFalse(material.isPurchased)
}
func testDisplayFormatters() {
XCTAssertEqual(TravelAlbumDisplayFormatter.maskPhone("13800138000"), "138****8000")
XCTAssertEqual(TravelAlbumDisplayFormatter.maskPhone(""), "--")
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2048), "2.0KB")
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2 * 1024 * 1024), "2.0MB")
}
@MainActor
func testBuildTodayAlbumNameUsesExistingSameDayCount() {
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date })
viewModel.onStateChange = {}
let api = TravelAlbumMockAPI()
api.listResponse = TravelAlbumListResponse(
total: 2,
list: [
TravelAlbum(id: 1, createdAt: "2026-07-07 09:00:00"),
TravelAlbum(id: 2, createdAt: "2026-07-06 09:00:00"),
]
)
let exp = expectation(description: "load")
Task {
await viewModel.loadAlbums(api: api)
XCTAssertEqual(viewModel.buildTodayAlbumName(), "2026-07-07-002")
exp.fulfill()
}
wait(for: [exp], timeout: 1)
}
}

View File

@ -0,0 +1,221 @@
//
// TravelAlbumViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class TravelAlbumEntryViewModelTests: XCTestCase {
func testLoadAlbumsUpdatesListAndTotal() async {
let api = TravelAlbumMockAPI()
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
await viewModel.loadAlbums(api: api)
XCTAssertEqual(viewModel.albumTotal, 1)
XCTAssertEqual(viewModel.albums.first?.name, "A")
}
func testPreShootRequiresSinglePrice() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.confirmCreate(mode: .preShoot, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)
XCTAssertEqual(message, "请输入单张照片价格")
XCTAssertEqual(api.createRequests.count, 0)
}
func testPreOrderRequiresOrder() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)
XCTAssertEqual(message, "请选择绑定订单")
}
func testCreateSuccessRefreshesAlbums() async {
let api = TravelAlbumMockAPI()
api.createResponse = TravelAlbumCreateResponse(id: 9)
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 9)])
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date })
await viewModel.confirmCreate(mode: .preShoot, freeCount: "2", singlePrice: "12.5", packagePrice: "88", order: nil, api: api)
XCTAssertEqual(api.createRequests.first?.name, "2026-07-07-001")
XCTAssertEqual(api.createRequests.first?.type, 1)
XCTAssertEqual(viewModel.albums.first?.id, 9)
XCTAssertFalse(viewModel.isCreateSheetVisible)
}
func testOrderAlreadyBoundReloadsOrders() async {
let api = TravelAlbumMockAPI()
api.createError = APIError.serverCode(100001, "订单已关联")
api.availableOrdersResponse = [TravelAlbumAvailableOrder(projectName: "新订单", orderNumber: "NO1")]
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
let order = TravelAlbumAvailableOrder(projectName: "项目", orderNumber: "NO0")
await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: order, api: api)
XCTAssertEqual(api.availableOrdersCallCount, 1)
XCTAssertEqual(viewModel.availableOrders.first?.projectName, "新订单")
}
}
@MainActor
/// ViewModel
final class TravelAlbumDetailViewModelTests: XCTestCase {
func testRefreshLoadsInfoAndMaterials() async {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListResponses = [
TravelAlbumListResponse(total: 2, list: []),
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.refreshAll(api: api)
XCTAssertEqual(viewModel.album?.name, "详情")
XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.materials.count, 2)
}
func testTabAndSortTriggerMaterialRequests() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.selectTab(.purchased, api: api)
await viewModel.setSortOption(.fileNameDesc, api: api)
XCTAssertEqual(api.materialRequests.first?.isPurchased, 1)
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
}
func testOnlyUnpurchasedMaterialCanBeSelected() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2))
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
}
func testDeleteAlbumCallsCallback() async {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
var deletedId = 0
viewModel.onDeletedAlbum = { deletedId = $0 }
await viewModel.deleteAlbum(api: api)
XCTAssertEqual(api.deletedAlbumIds, [5])
XCTAssertEqual(deletedId, 5)
}
}
/// 线 ViewModel
final class WiredCameraTransferViewModelTests: XCTestCase {
func testDefaultDisconnectedStateDoesNotDependOnBusinessServices() {
let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000")
var message = ""
viewModel.onShowMessage = { message = $0 }
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
viewModel.showUIOnlyMessage()
XCTAssertEqual(message, "仅同步 UI暂未接入 OTG 传输")
}
}
@MainActor
/// API
final class TravelAlbumMockAPI: TravelAlbumServing {
struct MaterialRequest: Equatable {
let userEquityTravelId: Int
let page: Int
let pageSize: Int
let orderBy: Int
let isPurchased: Int?
}
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
var listResponse = TravelAlbumListResponse<TravelAlbum>()
var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var createError: Error?
private(set) var availableOrdersCallCount = 0
private(set) var createRequests: [TravelAlbumCreateRequest] = []
private(set) var materialRequests: [MaterialRequest] = []
private(set) var deletedAlbumIds: [Int] = []
private(set) var deletedMaterialIds: [Int] = []
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
availableOrdersCallCount += 1
return availableOrdersResponse
}
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
createRequests.append(request)
if let createError { throw createError }
return createResponse
}
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
listResponse
}
func info(id: Int) async throws -> TravelAlbum {
infoResponse
}
func materialList(
userEquityTravelId: Int,
page: Int,
pageSize: Int,
orderBy: Int,
isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
materialRequests.append(
MaterialRequest(
userEquityTravelId: userEquityTravelId,
page: page,
pageSize: pageSize,
orderBy: orderBy,
isPurchased: isPurchased
)
)
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
return materialListResponses.removeFirst()
}
func deleteAlbum(id: Int) async throws {
deletedAlbumIds.append(id)
}
func deleteMaterial(id: Int) async throws {
deletedMaterialIds.append(id)
}
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
mpCodeResponse
}
}