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

@ -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"),

View File

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

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