feat: add travel album module
This commit is contained in:
@ -23,6 +23,7 @@ enum HomeMenuCatalog {
|
||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"),
|
||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"),
|
||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"),
|
||||
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),
|
||||
HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "photo.on.rectangle.angled"),
|
||||
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"),
|
||||
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),
|
||||
|
||||
@ -58,6 +58,8 @@ enum HomeRouteHandler {
|
||||
viewController.navigationController?.pushViewController(ScenicQueueViewController(), animated: true)
|
||||
case "task_management", "task_management_editor":
|
||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
case "travel_album":
|
||||
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
|
||||
case "wallet":
|
||||
showDeveloping(from: viewController)
|
||||
default:
|
||||
|
||||
139
suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift
Normal file
139
suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// TravelAlbumAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册服务协议,抽象相册列表、创建、详情与素材接口。
|
||||
protocol TravelAlbumServing {
|
||||
/// 拉取可绑定订单。
|
||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder]
|
||||
|
||||
/// 创建旅拍相册。
|
||||
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse
|
||||
|
||||
/// 拉取旅拍相册列表。
|
||||
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum>
|
||||
|
||||
/// 拉取旅拍相册详情。
|
||||
func info(id: Int) async throws -> TravelAlbum
|
||||
|
||||
/// 拉取旅拍相册素材列表。
|
||||
func materialList(
|
||||
userEquityTravelId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderBy: Int,
|
||||
isPurchased: Int?
|
||||
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
|
||||
|
||||
/// 删除旅拍相册。
|
||||
func deleteAlbum(id: Int) async throws
|
||||
|
||||
/// 删除旅拍相册素材。
|
||||
func deleteMaterial(id: Int) async throws
|
||||
|
||||
/// 拉取相册小程序码。
|
||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册 API,封装 Android `photog/travel-album` 接口组。
|
||||
final class TravelAlbumAPI: TravelAlbumServing {
|
||||
private let client: APIClient
|
||||
private let basePath = "/api/yf-handset-app/photog/travel-album"
|
||||
|
||||
/// 初始化旅拍相册 API。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取可绑定订单。
|
||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||
try await client.send(APIRequest(method: .get, path: "\(basePath)/available-order"))
|
||||
}
|
||||
|
||||
/// 创建旅拍相册。
|
||||
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
|
||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/create", body: request))
|
||||
}
|
||||
|
||||
/// 拉取旅拍相册列表。
|
||||
func list(page: Int = 1, pageSize: Int = 20) async throws -> TravelAlbumListResponse<TravelAlbum> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "\(basePath)/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取旅拍相册详情。
|
||||
func info(id: Int) async throws -> TravelAlbum {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "\(basePath)/info",
|
||||
queryItems: [URLQueryItem(name: "id", value: String(id))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取旅拍相册素材列表。
|
||||
func materialList(
|
||||
userEquityTravelId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20,
|
||||
orderBy: Int = 2,
|
||||
isPurchased: Int? = nil
|
||||
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
|
||||
var items = [
|
||||
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||
URLQueryItem(name: "order_by", value: String(orderBy)),
|
||||
]
|
||||
if let isPurchased {
|
||||
items.append(URLQueryItem(name: "is_purchased", value: String(isPurchased)))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "\(basePath)/material-list", queryItems: items)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除旅拍相册。
|
||||
func deleteAlbum(id: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "\(basePath)/delete", body: TravelAlbumIDRequest(id: id))
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除旅拍相册素材。
|
||||
func deleteMaterial(id: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id))
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取相册小程序码。
|
||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "\(basePath)/mp-code",
|
||||
queryItems: [URLQueryItem(name: "id", value: String(id))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册 ID 请求体。
|
||||
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
||||
let id: Int
|
||||
}
|
||||
256
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
256
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
@ -0,0 +1,256 @@
|
||||
//
|
||||
// TravelAlbumModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。
|
||||
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let phone: String
|
||||
|
||||
init(id: Int = 0, phone: String = "") {
|
||||
self.id = id
|
||||
self.phone = phone
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册实体,对齐 Android `TravelAlbumEntity`。
|
||||
struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let storeUserId: Int
|
||||
let name: String
|
||||
let type: Int
|
||||
let orderNumber: String
|
||||
let materialNum: Int
|
||||
let materialPrice: Int
|
||||
let materialPackagePrice: Int
|
||||
let photoPrice: Int
|
||||
let coverUrl: String
|
||||
let userId: Int
|
||||
let status: Int
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let user: TravelAlbumUser?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case storeUserId = "store_user_id"
|
||||
case name
|
||||
case type
|
||||
case orderNumber = "order_number"
|
||||
case materialNum = "material_num"
|
||||
case materialPrice = "material_price"
|
||||
case materialPackagePrice = "material_package_price"
|
||||
case photoPrice = "photo_price"
|
||||
case coverUrl = "cover_url"
|
||||
case userId = "user_id"
|
||||
case status
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case user
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
storeUserId: Int = 0,
|
||||
name: String = "",
|
||||
type: Int = 0,
|
||||
orderNumber: String = "",
|
||||
materialNum: Int = 0,
|
||||
materialPrice: Int = 0,
|
||||
materialPackagePrice: Int = 0,
|
||||
photoPrice: Int = 0,
|
||||
coverUrl: String = "",
|
||||
userId: Int = 0,
|
||||
status: Int = 0,
|
||||
createdAt: String = "",
|
||||
updatedAt: String = "",
|
||||
user: TravelAlbumUser? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.storeUserId = storeUserId
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.orderNumber = orderNumber
|
||||
self.materialNum = materialNum
|
||||
self.materialPrice = materialPrice
|
||||
self.materialPackagePrice = materialPackagePrice
|
||||
self.photoPrice = photoPrice
|
||||
self.coverUrl = coverUrl
|
||||
self.userId = userId
|
||||
self.status = status
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.user = user
|
||||
}
|
||||
|
||||
/// 展示用手机号。
|
||||
var displayPhone: String {
|
||||
user?.phone ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册可绑定订单,对齐 Android `TravelAlbumAvailableOrderEntity`。
|
||||
struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
|
||||
let projectName: String
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let userId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case orderNumber = "order_number"
|
||||
case userPhone = "user_phone"
|
||||
case userId = "user_id"
|
||||
}
|
||||
|
||||
init(projectName: String = "", orderNumber: String = "", userPhone: String = "", userId: Int = 0) {
|
||||
self.projectName = projectName
|
||||
self.orderNumber = orderNumber
|
||||
self.userPhone = userPhone
|
||||
self.userId = userId
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
|
||||
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let userEquityTravelId: Int
|
||||
let status: Int
|
||||
let orderNumber: String
|
||||
let userId: Int
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let fileUrl: String
|
||||
let fileSize: Int
|
||||
let coverUrl: String
|
||||
let isPurchased: Bool
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
case status
|
||||
case orderNumber = "order_number"
|
||||
case userId = "user_id"
|
||||
case fileName = "file_name"
|
||||
case fileType = "file_type"
|
||||
case fileUrl = "file_url"
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
case isPurchased = "is_purchased"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
userEquityTravelId: Int = 0,
|
||||
status: Int = 0,
|
||||
orderNumber: String = "",
|
||||
userId: Int = 0,
|
||||
fileName: String = "",
|
||||
fileType: Int = 0,
|
||||
fileUrl: String = "",
|
||||
fileSize: Int = 0,
|
||||
coverUrl: String = "",
|
||||
isPurchased: Bool = false,
|
||||
createdAt: String = "",
|
||||
updatedAt: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.userEquityTravelId = userEquityTravelId
|
||||
self.status = status
|
||||
self.orderNumber = orderNumber
|
||||
self.userId = userId
|
||||
self.fileName = fileName
|
||||
self.fileType = fileType
|
||||
self.fileUrl = fileUrl
|
||||
self.fileSize = fileSize
|
||||
self.coverUrl = coverUrl
|
||||
self.isPurchased = isPurchased
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
|
||||
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||
let name: String
|
||||
let type: Int
|
||||
let orderNumber: String?
|
||||
let materialNum: Int?
|
||||
let materialPrice: Double?
|
||||
let materialPackagePrice: Double?
|
||||
let photoPrice: Double?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case type
|
||||
case orderNumber = "order_number"
|
||||
case materialNum = "material_num"
|
||||
case materialPrice = "material_price"
|
||||
case materialPackagePrice = "material_package_price"
|
||||
case photoPrice = "photo_price"
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册通用分页响应。
|
||||
struct TravelAlbumListResponse<Item: Decodable & Sendable & Equatable>: Decodable, Sendable, Equatable {
|
||||
let total: Int
|
||||
let list: [Item]
|
||||
|
||||
init(total: Int = 0, list: [Item] = []) {
|
||||
self.total = total
|
||||
self.list = list
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册创建响应。
|
||||
struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
|
||||
let id: Int
|
||||
}
|
||||
|
||||
/// 旅拍相册小程序码响应。
|
||||
struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
|
||||
let mpCodeOssUrl: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case mpCodeOssUrl = "mp_code_oss_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册展示格式化工具。
|
||||
enum TravelAlbumDisplayFormatter {
|
||||
/// 脱敏手机号。
|
||||
static func maskPhone(_ phone: String) -> String {
|
||||
guard phone.count >= 7 else { return phone.isEmpty ? "--" : phone }
|
||||
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
||||
}
|
||||
|
||||
/// 生成文件大小文本。
|
||||
static func fileSizeText(_ bytes: Int) -> String {
|
||||
let value = Double(max(bytes, 0))
|
||||
if value >= 1024 * 1024 * 1024 {
|
||||
return String(format: "%.1fGB", value / 1024 / 1024 / 1024)
|
||||
}
|
||||
if value >= 1024 * 1024 {
|
||||
return String(format: "%.1fMB", value / 1024 / 1024)
|
||||
}
|
||||
if value >= 1024 {
|
||||
return String(format: "%.1fKB", value / 1024)
|
||||
}
|
||||
return "\(Int(value))B"
|
||||
}
|
||||
|
||||
/// 创建时间短展示。
|
||||
static func shortTaskTime(_ text: String) -> String {
|
||||
if text.count >= 16 {
|
||||
return String(text.prefix(16))
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,231 @@
|
||||
//
|
||||
// TravelAlbumDetailViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册详情 ViewModel,负责相册信息、素材分页筛选和删除状态。
|
||||
final class TravelAlbumDetailViewModel {
|
||||
enum Tab: Int, Sendable {
|
||||
case all = 0
|
||||
case purchased = 1
|
||||
}
|
||||
|
||||
/// 排序方式。
|
||||
enum SortOption: Int, CaseIterable, Sendable {
|
||||
case createdAsc = 1
|
||||
case createdDesc = 2
|
||||
case fileNameAsc = 3
|
||||
case fileNameDesc = 4
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .createdAsc: "创建时间正序"
|
||||
case .createdDesc: "创建时间倒序"
|
||||
case .fileNameAsc: "文件名正序"
|
||||
case .fileNameDesc: "文件名倒序"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var album: TravelAlbum?
|
||||
private(set) var materials: [TravelAlbumMaterial] = []
|
||||
private(set) var allPhotoCount = 0
|
||||
private(set) var selectedTab: Tab = .all
|
||||
private(set) var sortOption: SortOption = .createdDesc
|
||||
private(set) var isLoading = true
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isSelectionMode = false
|
||||
private(set) var selectedMaterialIds: Set<Int> = []
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onDeletedAlbum: ((Int) -> Void)?
|
||||
|
||||
let albumId: Int
|
||||
private var currentPage = 1
|
||||
private var canLoadMore = false
|
||||
private var isLoadingMore = false
|
||||
private let pageSize = 30
|
||||
|
||||
init(albumId: Int) {
|
||||
self.albumId = albumId
|
||||
}
|
||||
|
||||
/// 重新加载详情与素材。
|
||||
func refreshAll(api: any TravelAlbumServing) async {
|
||||
guard albumId > 0 else {
|
||||
isLoading = false
|
||||
onShowMessage?("相册不存在")
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
isRefreshing = true
|
||||
notifyStateChange()
|
||||
await loadAlbumInfo(api: api)
|
||||
await loadAllPhotoCount(api: api)
|
||||
await loadMaterials(reset: true, api: api)
|
||||
isRefreshing = false
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 拉取相册详情。
|
||||
func loadAlbumInfo(api: any TravelAlbumServing) async {
|
||||
do {
|
||||
album = try await api.info(id: albumId)
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取全部照片数量。
|
||||
func loadAllPhotoCount(api: any TravelAlbumServing) async {
|
||||
do {
|
||||
let response = try await api.materialList(
|
||||
userEquityTravelId: albumId,
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
orderBy: sortOption.rawValue,
|
||||
isPurchased: nil
|
||||
)
|
||||
allPhotoCount = response.total
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换素材 tab。
|
||||
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||
guard selectedTab != tab else { return }
|
||||
selectedTab = tab
|
||||
isSelectionMode = false
|
||||
selectedMaterialIds = []
|
||||
notifyStateChange()
|
||||
await loadMaterials(reset: true, api: api)
|
||||
}
|
||||
|
||||
/// 切换排序。
|
||||
func setSortOption(_ option: SortOption, api: any TravelAlbumServing) async {
|
||||
guard sortOption != option else { return }
|
||||
sortOption = option
|
||||
notifyStateChange()
|
||||
await loadMaterials(reset: true, api: api)
|
||||
}
|
||||
|
||||
/// 拉取素材列表。
|
||||
func loadMaterials(reset: Bool, api: any TravelAlbumServing) async {
|
||||
guard albumId > 0 else { return }
|
||||
if reset {
|
||||
currentPage = 1
|
||||
canLoadMore = false
|
||||
} else {
|
||||
guard canLoadMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
currentPage += 1
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.materialList(
|
||||
userEquityTravelId: albumId,
|
||||
page: currentPage,
|
||||
pageSize: pageSize,
|
||||
orderBy: sortOption.rawValue,
|
||||
isPurchased: selectedTab == .purchased ? 1 : nil
|
||||
)
|
||||
materials = reset ? response.list : materials + response.list
|
||||
canLoadMore = materials.count < response.total
|
||||
if selectedTab == .all {
|
||||
allPhotoCount = response.total
|
||||
}
|
||||
isLoadingMore = false
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
if !reset && currentPage > 1 {
|
||||
currentPage -= 1
|
||||
}
|
||||
isLoadingMore = false
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据滚动位置触发加载更多。
|
||||
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any TravelAlbumServing) async {
|
||||
if lastVisibleIndex >= materials.count - 6 {
|
||||
await loadMaterials(reset: false, api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换选择模式。
|
||||
func toggleSelectionMode() {
|
||||
guard selectedTab == .all else { return }
|
||||
isSelectionMode.toggle()
|
||||
if !isSelectionMode {
|
||||
selectedMaterialIds = []
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 切换素材选中状态。
|
||||
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||
guard isSelectionMode else { return }
|
||||
guard material.status == 1 else {
|
||||
onShowMessage?("仅未购买素材可删除")
|
||||
return
|
||||
}
|
||||
if selectedMaterialIds.contains(material.id) {
|
||||
selectedMaterialIds.remove(material.id)
|
||||
} else {
|
||||
selectedMaterialIds.insert(material.id)
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 删除已选素材。
|
||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||
let ids = Array(selectedMaterialIds)
|
||||
guard !ids.isEmpty else {
|
||||
onShowMessage?("请选择要删除的素材")
|
||||
return
|
||||
}
|
||||
do {
|
||||
for id in ids {
|
||||
try await api.deleteMaterial(id: id)
|
||||
}
|
||||
onShowMessage?("删除成功")
|
||||
isSelectionMode = false
|
||||
selectedMaterialIds = []
|
||||
await refreshAll(api: api)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除当前相册。
|
||||
func deleteAlbum(api: any TravelAlbumServing) async {
|
||||
do {
|
||||
try await api.deleteAlbum(id: albumId)
|
||||
onShowMessage?("相册已删除")
|
||||
onDeletedAlbum?(albumId)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
//
|
||||
// TravelAlbumEntryViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 旅拍相册入口页 ViewModel,负责列表、创建相册和相册码状态。
|
||||
final class TravelAlbumEntryViewModel {
|
||||
enum CreateMode: Int, Sendable {
|
||||
case preShoot = 0
|
||||
case preOrder = 1
|
||||
|
||||
var apiType: Int {
|
||||
switch self {
|
||||
case .preShoot: 1
|
||||
case .preOrder: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册码弹窗状态。
|
||||
struct AlbumCodeState: Sendable, Equatable {
|
||||
let albumId: Int
|
||||
let albumName: String
|
||||
var qrImageUrl: String
|
||||
var isLoading: Bool
|
||||
var isDownloading: Bool
|
||||
}
|
||||
|
||||
private(set) var albums: [TravelAlbum] = []
|
||||
private(set) var albumTotal = 0
|
||||
private(set) var availableOrders: [TravelAlbumAvailableOrder] = []
|
||||
private(set) var isLoading = false
|
||||
private(set) var isCreating = false
|
||||
private(set) var isCreateSheetVisible = false
|
||||
private(set) var albumCodeState: AlbumCodeState?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onCreatedAlbum: ((TravelAlbumCreateResponse) -> Void)?
|
||||
|
||||
private let currentScenicIdProvider: () -> Int
|
||||
private let dateProvider: () -> Date
|
||||
|
||||
init(
|
||||
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId },
|
||||
dateProvider: @escaping () -> Date = Date.init
|
||||
) {
|
||||
self.currentScenicIdProvider = currentScenicIdProvider
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
/// 重新拉取相册列表。
|
||||
func loadAlbums(api: any TravelAlbumServing) async {
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.list(page: 1, pageSize: 20)
|
||||
albumTotal = response.total
|
||||
albums = response.list
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
albums = []
|
||||
albumTotal = 0
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开创建相册弹窗。
|
||||
func openCreateSheet(api: any TravelAlbumServing) async {
|
||||
guard !isCreating else { return }
|
||||
guard currentScenicIdProvider() > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return
|
||||
}
|
||||
isCreateSheetVisible = true
|
||||
notifyStateChange()
|
||||
await loadAvailableOrders(api: api)
|
||||
}
|
||||
|
||||
/// 关闭创建相册弹窗。
|
||||
func dismissCreateSheet() {
|
||||
isCreateSheetVisible = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 拉取可绑定订单。
|
||||
func loadAvailableOrders(api: any TravelAlbumServing) async {
|
||||
do {
|
||||
availableOrders = try await api.availableOrders()
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
availableOrders = []
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验并创建相册任务。
|
||||
func confirmCreate(
|
||||
mode: CreateMode,
|
||||
freeCount: String,
|
||||
singlePrice: String,
|
||||
packagePrice: String,
|
||||
order: TravelAlbumAvailableOrder?,
|
||||
api: any TravelAlbumServing
|
||||
) async {
|
||||
guard !isCreating else { return }
|
||||
guard currentScenicIdProvider() > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return
|
||||
}
|
||||
|
||||
if mode == .preOrder && order == nil {
|
||||
onShowMessage?("请选择绑定订单")
|
||||
return
|
||||
}
|
||||
if mode == .preShoot && singlePrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
onShowMessage?("请输入单张照片价格")
|
||||
return
|
||||
}
|
||||
let materialPrice = Double(singlePrice)
|
||||
if mode == .preShoot && materialPrice == nil {
|
||||
onShowMessage?("请输入有效的单张照片价格")
|
||||
return
|
||||
}
|
||||
|
||||
let albumName: String
|
||||
switch mode {
|
||||
case .preOrder:
|
||||
albumName = order?.projectName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? buildTodayAlbumName()
|
||||
case .preShoot:
|
||||
albumName = buildTodayAlbumName()
|
||||
}
|
||||
|
||||
let request: TravelAlbumCreateRequest
|
||||
switch mode {
|
||||
case .preShoot:
|
||||
request = TravelAlbumCreateRequest(
|
||||
name: albumName,
|
||||
type: mode.apiType,
|
||||
orderNumber: nil,
|
||||
materialNum: Int(freeCount) ?? 0,
|
||||
materialPrice: materialPrice,
|
||||
materialPackagePrice: Double(packagePrice) ?? 0,
|
||||
photoPrice: 0
|
||||
)
|
||||
case .preOrder:
|
||||
request = TravelAlbumCreateRequest(
|
||||
name: albumName,
|
||||
type: mode.apiType,
|
||||
orderNumber: order?.orderNumber,
|
||||
materialNum: nil,
|
||||
materialPrice: nil,
|
||||
materialPackagePrice: nil,
|
||||
photoPrice: nil
|
||||
)
|
||||
}
|
||||
|
||||
isCreating = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isCreating = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.create(request)
|
||||
isCreateSheetVisible = false
|
||||
onShowMessage?("任务创建成功")
|
||||
onCreatedAlbum?(response)
|
||||
await loadAlbums(api: api)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
if message.contains("订单已关联") {
|
||||
onShowMessage?("该订单已关联旅拍相册,请刷新后重新选择")
|
||||
await loadAvailableOrders(api: api)
|
||||
} else {
|
||||
onShowMessage?(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开相册码弹窗并加载二维码。
|
||||
func openAlbumCode(album: TravelAlbum, api: any TravelAlbumServing) async {
|
||||
albumCodeState = AlbumCodeState(
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
qrImageUrl: "",
|
||||
isLoading: true,
|
||||
isDownloading: false
|
||||
)
|
||||
notifyStateChange()
|
||||
do {
|
||||
let response = try await api.mpCode(id: album.id)
|
||||
guard albumCodeState?.albumId == album.id else { return }
|
||||
albumCodeState?.qrImageUrl = response.mpCodeOssUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
albumCodeState?.isLoading = false
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard albumCodeState?.albumId == album.id else { return }
|
||||
albumCodeState?.isLoading = false
|
||||
notifyStateChange()
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "获取小程序码失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 关闭相册码弹窗。
|
||||
func dismissAlbumCode() {
|
||||
albumCodeState = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 设置二维码下载状态。
|
||||
func setDownloadingQRCode(_ isDownloading: Bool) {
|
||||
albumCodeState?.isDownloading = isDownloading
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 生成 Android 同款今日相册名。
|
||||
func buildTodayAlbumName() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
let todayText = formatter.string(from: dateProvider())
|
||||
let todayCount = albums.filter { $0.createdAt.hasPrefix(todayText) }.count
|
||||
return "\(todayText)-\(String(format: "%03d", todayCount + 1))"
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
//
|
||||
// WiredCameraTransferViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 有线相机传输页 ViewModel,仅承载断开空态 UI 文案,不接入 OTG 或图片存储业务。
|
||||
final class WiredCameraTransferViewModel {
|
||||
let albumId: Int
|
||||
let albumTitle: String
|
||||
let headerPhone: String
|
||||
let scenicSpotLabel: String?
|
||||
let cameraStatusText = "未连接 USB"
|
||||
let actionButtonText = "等待连接"
|
||||
let deviceModelName = "设备存储"
|
||||
let availableStorageText = "-- GB 可用"
|
||||
let retouchOption = "不修图"
|
||||
let photoFormatOption = "JPG"
|
||||
let transferModeOption = "边拍边传"
|
||||
let tabCounts = [0, 0, 0]
|
||||
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
init(albumId: Int, albumTitle: String, headerPhone: String, scenicSpotLabel: String? = nil) {
|
||||
self.albumId = albumId
|
||||
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
|
||||
self.headerPhone = headerPhone
|
||||
self.scenicSpotLabel = scenicSpotLabel
|
||||
}
|
||||
|
||||
/// 响应所有传输按钮点击,仅提示 UI 已同步。
|
||||
func showUIOnlyMessage() {
|
||||
onShowMessage?("仅同步 UI,暂未接入 OTG 传输")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user