为旅拍 OTG 上传增加 client_photo_id,并按服务端已登记 ID 同步本地上传状态。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -32,6 +32,9 @@ protocol TravelAlbumServing {
|
|||||||
/// 上传并登记旅拍相册素材。
|
/// 上传并登记旅拍相册素材。
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
|
||||||
|
|
||||||
|
/// 拉取相册内已登记素材的客户端照片 ID。
|
||||||
|
func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse
|
||||||
|
|
||||||
/// 删除旅拍相册。
|
/// 删除旅拍相册。
|
||||||
func deleteAlbum(id: Int) async throws
|
func deleteAlbum(id: Int) async throws
|
||||||
|
|
||||||
@ -115,6 +118,19 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取相册内已登记素材的客户端照片 ID。
|
||||||
|
func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/material-client-photo-ids",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除旅拍相册。
|
/// 删除旅拍相册。
|
||||||
func deleteAlbum(id: Int) async throws {
|
func deleteAlbum(id: Int) async throws {
|
||||||
let _: EmptyPayload = try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
|
|||||||
@ -203,11 +203,22 @@ struct TravelAlbumUploadMaterialRequest: Encodable, Sendable, Equatable {
|
|||||||
let userEquityTravelId: Int
|
let userEquityTravelId: Int
|
||||||
let fileName: String
|
let fileName: String
|
||||||
let fileUrl: String
|
let fileUrl: String
|
||||||
|
let clientPhotoId: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case userEquityTravelId = "user_equity_travel_id"
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
case fileName = "file_name"
|
case fileName = "file_name"
|
||||||
case fileUrl = "file_url"
|
case fileUrl = "file_url"
|
||||||
|
case clientPhotoId = "client_photo_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册服务端已登记的客户端照片 ID 集合。
|
||||||
|
struct TravelAlbumMaterialClientPhotoIDsResponse: Decodable, Sendable, Equatable {
|
||||||
|
let clientPhotoIds: [String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case clientPhotoIds = "client_photo_ids"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,25 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// 生成并规范化旅拍照片的客户端唯一标识。
|
||||||
|
enum TravelAlbumClientPhotoID {
|
||||||
|
/// 生成小写、带连字符的 UUID v4 字符串。
|
||||||
|
static func make() -> String {
|
||||||
|
UUID().uuidString.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规范化服务端或本地保存的客户端照片 ID。
|
||||||
|
static func normalize(_ value: String?) -> String? {
|
||||||
|
guard let normalized = value?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased(),
|
||||||
|
!normalized.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// OTG 传输照片上传状态,对齐 Android `WiredTransferUploadStatus`。
|
/// OTG 传输照片上传状态,对齐 Android `WiredTransferUploadStatus`。
|
||||||
enum TravelAlbumOTGUploadStatus: String, Codable, Equatable, Sendable {
|
enum TravelAlbumOTGUploadStatus: String, Codable, Equatable, Sendable {
|
||||||
case pending = "PENDING"
|
case pending = "PENDING"
|
||||||
@ -25,6 +44,7 @@ enum TravelAlbumOTGPhotoFormatOption: String, CaseIterable, Equatable, Sendable
|
|||||||
struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||||
let id: String
|
let id: String
|
||||||
let sourceId: String
|
let sourceId: String
|
||||||
|
var clientPhotoId: String
|
||||||
let fileName: String
|
let fileName: String
|
||||||
var localPath: String
|
var localPath: String
|
||||||
var thumbnailPath: String
|
var thumbnailPath: String
|
||||||
@ -42,6 +62,7 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
|||||||
init(
|
init(
|
||||||
id: String,
|
id: String,
|
||||||
sourceId: String? = nil,
|
sourceId: String? = nil,
|
||||||
|
clientPhotoId: String = TravelAlbumClientPhotoID.make(),
|
||||||
fileName: String,
|
fileName: String,
|
||||||
localPath: String,
|
localPath: String,
|
||||||
thumbnailPath: String = "",
|
thumbnailPath: String = "",
|
||||||
@ -57,6 +78,7 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
|||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.sourceId = sourceId ?? id
|
self.sourceId = sourceId ?? id
|
||||||
|
self.clientPhotoId = TravelAlbumClientPhotoID.normalize(clientPhotoId) ?? TravelAlbumClientPhotoID.make()
|
||||||
self.fileName = fileName
|
self.fileName = fileName
|
||||||
self.localPath = localPath
|
self.localPath = localPath
|
||||||
self.thumbnailPath = thumbnailPath
|
self.thumbnailPath = thumbnailPath
|
||||||
@ -71,6 +93,31 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
|||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
|
||||||
|
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(String.self, forKey: .id)
|
||||||
|
sourceId = try container.decodeIfPresent(String.self, forKey: .sourceId) ?? id
|
||||||
|
clientPhotoId = try container.decodeIfPresent(String.self, forKey: .clientPhotoId) ?? ""
|
||||||
|
fileName = try container.decode(String.self, forKey: .fileName)
|
||||||
|
localPath = try container.decode(String.self, forKey: .localPath)
|
||||||
|
thumbnailPath = try container.decodeIfPresent(String.self, forKey: .thumbnailPath) ?? ""
|
||||||
|
capturedAt = try container.decode(String.self, forKey: .capturedAt)
|
||||||
|
fileSizeBytes = try container.decodeIfPresent(Int64.self, forKey: .fileSizeBytes) ?? 0
|
||||||
|
status = try container.decode(TravelAlbumOTGUploadStatus.self, forKey: .status)
|
||||||
|
progress = try container.decodeIfPresent(Int.self, forKey: .progress) ?? 0
|
||||||
|
errorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage)
|
||||||
|
albumId = try container.decode(Int.self, forKey: .albumId)
|
||||||
|
userId = try container.decode(String.self, forKey: .userId)
|
||||||
|
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
|
||||||
|
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
||||||
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
||||||
guard status == .transferring || status == .uploading else { return self }
|
guard status == .transferring || status == .uploading else { return self }
|
||||||
@ -188,9 +235,17 @@ final class TravelAlbumOTGPhotoStore {
|
|||||||
let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else {
|
let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return decoded
|
let scoped = decoded.filter { $0.albumId == albumId && $0.userId == context.userId }
|
||||||
.filter { $0.albumId == albumId && $0.userId == context.userId }
|
let migrated = scoped.map { record -> TravelAlbumOTGPhotoRecord in
|
||||||
.map { $0.normalizedAfterInterruptedTransfer() }
|
var copy = record
|
||||||
|
copy.clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId)
|
||||||
|
?? TravelAlbumClientPhotoID.make()
|
||||||
|
return copy.normalizedAfterInterruptedTransfer()
|
||||||
|
}
|
||||||
|
if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) {
|
||||||
|
save(migrated, albumId: albumId)
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
.filter { isDisplayable($0, albumId: albumId) }
|
.filter { isDisplayable($0, albumId: albumId) }
|
||||||
.sorted { $0.capturedAt > $1.capturedAt }
|
.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
}
|
}
|
||||||
@ -434,6 +489,40 @@ final class TravelAlbumOTGPhotoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按服务端客户端照片 ID 快照回写本地持久状态。
|
||||||
|
enum TravelAlbumOTGServerStatusPolicy {
|
||||||
|
/// 只处理请求发起时已存在且期间未变化的非活动记录。
|
||||||
|
static func reconcile(
|
||||||
|
records: [TravelAlbumOTGPhotoRecord],
|
||||||
|
baselineRecordsById: [String: TravelAlbumOTGPhotoRecord],
|
||||||
|
activePhotoIds: Set<String>,
|
||||||
|
serverClientPhotoIds: [String],
|
||||||
|
now: Int64
|
||||||
|
) -> [TravelAlbumOTGPhotoRecord] {
|
||||||
|
let serverIds = Set(serverClientPhotoIds.compactMap(TravelAlbumClientPhotoID.normalize))
|
||||||
|
return records.map { record in
|
||||||
|
guard baselineRecordsById[record.id] == record,
|
||||||
|
!activePhotoIds.contains(record.id) else {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
var copy = record
|
||||||
|
if let clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId),
|
||||||
|
serverIds.contains(clientPhotoId) {
|
||||||
|
copy.status = .uploaded
|
||||||
|
copy.progress = 100
|
||||||
|
copy.errorMessage = nil
|
||||||
|
copy.updatedAt = now
|
||||||
|
} else if record.status == .uploaded {
|
||||||
|
copy.status = .pending
|
||||||
|
copy.progress = 0
|
||||||
|
copy.errorMessage = nil
|
||||||
|
copy.updatedAt = now
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 兼容从 `otg_swift` 同步来的边拍边传写入调用。
|
/// 兼容从 `otg_swift` 同步来的边拍边传写入调用。
|
||||||
enum AlbumPhotoStorage {
|
enum AlbumPhotoStorage {
|
||||||
/// 写入原片数据到当前账号与相册隔离目录。
|
/// 写入原片数据到当前账号与相册隔离目录。
|
||||||
|
|||||||
@ -44,6 +44,9 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
|||||||
FileManager.default.fileExists(atPath: record.localPath) else {
|
FileManager.default.fileExists(atPath: record.localPath) else {
|
||||||
throw TravelAlbumOTGUploadError.localFileMissing
|
throw TravelAlbumOTGUploadError.localFileMissing
|
||||||
}
|
}
|
||||||
|
guard let clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId) else {
|
||||||
|
throw TravelAlbumOTGUploadError.clientPhotoIdMissing
|
||||||
|
}
|
||||||
|
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: record.localPath))
|
let data = try Data(contentsOf: URL(fileURLWithPath: record.localPath))
|
||||||
let remoteURL = try await uploader.uploadTravelAlbumMaterial(
|
let remoteURL = try await uploader.uploadTravelAlbumMaterial(
|
||||||
@ -56,7 +59,8 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
|||||||
TravelAlbumUploadMaterialRequest(
|
TravelAlbumUploadMaterialRequest(
|
||||||
userEquityTravelId: record.albumId,
|
userEquityTravelId: record.albumId,
|
||||||
fileName: record.fileName,
|
fileName: record.fileName,
|
||||||
fileUrl: remoteURL
|
fileUrl: remoteURL,
|
||||||
|
clientPhotoId: clientPhotoId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -66,6 +70,7 @@ final class TravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
|||||||
enum TravelAlbumOTGUploadError: LocalizedError, Equatable {
|
enum TravelAlbumOTGUploadError: LocalizedError, Equatable {
|
||||||
case invalidAlbum
|
case invalidAlbum
|
||||||
case localFileMissing
|
case localFileMissing
|
||||||
|
case clientPhotoIdMissing
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
@ -73,6 +78,8 @@ enum TravelAlbumOTGUploadError: LocalizedError, Equatable {
|
|||||||
return "请先选择有效的相册"
|
return "请先选择有效的相册"
|
||||||
case .localFileMissing:
|
case .localFileMissing:
|
||||||
return "本地文件不存在,无法上传"
|
return "本地文件不存在,无法上传"
|
||||||
|
case .clientPhotoIdMissing:
|
||||||
|
return "照片标识缺失,请重新导入后重试"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,7 @@ final class WiredCameraTransferViewModel {
|
|||||||
private let storage: TravelAlbumOTGPhotoStore
|
private let storage: TravelAlbumOTGPhotoStore
|
||||||
private let repository: TravelAlbumOTGPhotoRepository
|
private let repository: TravelAlbumOTGPhotoRepository
|
||||||
private let uploader: any TravelAlbumOTGUploading
|
private let uploader: any TravelAlbumOTGUploading
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
private let appStore: AppStore
|
private let appStore: AppStore
|
||||||
private let userDefaults: UserDefaults
|
private let userDefaults: UserDefaults
|
||||||
|
|
||||||
@ -85,6 +86,7 @@ final class WiredCameraTransferViewModel {
|
|||||||
private var isUploading = false
|
private var isUploading = false
|
||||||
private var queuedAutoUploadPhotoIds: Set<String> = []
|
private var queuedAutoUploadPhotoIds: Set<String> = []
|
||||||
private var suppressSelectedTimeSlotNotification = false
|
private var suppressSelectedTimeSlotNotification = false
|
||||||
|
private var serverStatusSyncTask: Task<Void, Never>?
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(
|
init(
|
||||||
@ -95,6 +97,7 @@ final class WiredCameraTransferViewModel {
|
|||||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||||
|
api: (any TravelAlbumServing)? = nil,
|
||||||
appStore: AppStore = .shared,
|
appStore: AppStore = .shared,
|
||||||
userDefaults: UserDefaults = .standard
|
userDefaults: UserDefaults = .standard
|
||||||
) {
|
) {
|
||||||
@ -106,6 +109,7 @@ final class WiredCameraTransferViewModel {
|
|||||||
self.storage = storage
|
self.storage = storage
|
||||||
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
|
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
|
||||||
self.uploader = uploader ?? TravelAlbumOTGUploader()
|
self.uploader = uploader ?? TravelAlbumOTGUploader()
|
||||||
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
self.appStore = appStore
|
self.appStore = appStore
|
||||||
self.userDefaults = userDefaults
|
self.userDefaults = userDefaults
|
||||||
self.transferMode = Self.persistedTransferMode(in: userDefaults)
|
self.transferMode = Self.persistedTransferMode(in: userDefaults)
|
||||||
@ -225,11 +229,14 @@ final class WiredCameraTransferViewModel {
|
|||||||
connectionManager.delegate = self
|
connectionManager.delegate = self
|
||||||
syncFromConnectionManager()
|
syncFromConnectionManager()
|
||||||
loadPersistedPhotos()
|
loadPersistedPhotos()
|
||||||
|
syncServerUploadStatuses()
|
||||||
connectionManager.start()
|
connectionManager.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 页面消失时解绑 UI delegate,并暂停边拍边传轮询,保留底层设备缓存。
|
/// 页面消失时解绑 UI delegate,并暂停边拍边传轮询,保留底层设备缓存。
|
||||||
func stop() {
|
func stop() {
|
||||||
|
serverStatusSyncTask?.cancel()
|
||||||
|
serverStatusSyncTask = nil
|
||||||
connectionManager.unbindDelegate()
|
connectionManager.unbindDelegate()
|
||||||
connectionManager.suspendLiveTransfer()
|
connectionManager.suspendLiveTransfer()
|
||||||
}
|
}
|
||||||
@ -483,6 +490,39 @@ final class WiredCameraTransferViewModel {
|
|||||||
applyMergedPhotos()
|
applyMergedPhotos()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncServerUploadStatuses() {
|
||||||
|
serverStatusSyncTask?.cancel()
|
||||||
|
guard albumId > 0 else { return }
|
||||||
|
let baseline = persistedRecordsById
|
||||||
|
serverStatusSyncTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let response = try await api.materialClientPhotoIds(userEquityTravelId: albumId)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let activeIds = Set(persistedRecordsById.values.compactMap { record in
|
||||||
|
record.status == .transferring || record.status == .uploading ? record.id : nil
|
||||||
|
})
|
||||||
|
let reconciled = TravelAlbumOTGServerStatusPolicy.reconcile(
|
||||||
|
records: Array(persistedRecordsById.values),
|
||||||
|
baselineRecordsById: baseline,
|
||||||
|
activePhotoIds: activeIds,
|
||||||
|
serverClientPhotoIds: response.clientPhotoIds,
|
||||||
|
now: Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
)
|
||||||
|
let reconciledById = Dictionary(uniqueKeysWithValues: reconciled.map { ($0.id, $0) })
|
||||||
|
guard reconciledById != persistedRecordsById else { return }
|
||||||
|
persistedRecordsById = reconciledById
|
||||||
|
storage.save(reconciled, albumId: albumId)
|
||||||
|
applyMergedPhotos()
|
||||||
|
selectedPhotoIds = selectedPhotoIds.intersection(selectableVisiblePhotoIds)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
OTGLog.error(.connection, "sync server upload statuses failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func applyMergedPhotos() {
|
private func applyMergedPhotos() {
|
||||||
let persistedItems = persistedRecordsById.values
|
let persistedItems = persistedRecordsById.values
|
||||||
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
|
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
|
||||||
|
|||||||
@ -12,19 +12,16 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
|
|
||||||
private let viewModel: TravelAlbumEntryViewModel
|
private let viewModel: TravelAlbumEntryViewModel
|
||||||
private let api: any TravelAlbumServing
|
private let api: any TravelAlbumServing
|
||||||
private var mode: TravelAlbumEntryViewModel.CreateMode = .preShoot
|
private let mode: TravelAlbumEntryViewModel.CreateMode = .preShoot
|
||||||
private var selectedOrder: TravelAlbumAvailableOrder?
|
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentView = UIView()
|
private let contentView = UIView()
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let preShootCard = TravelAlbumModeOptionView()
|
private let preShootCard = TravelAlbumModeOptionView()
|
||||||
private let preOrderCard = TravelAlbumModeOptionView()
|
|
||||||
private let fieldsStack = UIStackView()
|
private let fieldsStack = UIStackView()
|
||||||
private let freeCountField = UITextField()
|
private let freeCountField = UITextField()
|
||||||
private let singlePriceField = UITextField()
|
private let singlePriceField = UITextField()
|
||||||
private let packagePriceField = UITextField()
|
private let packagePriceField = UITextField()
|
||||||
private let orderButton = UIButton(type: .system)
|
|
||||||
private let cancelButton = UIButton(type: .system)
|
private let cancelButton = UIButton(type: .system)
|
||||||
private let confirmButton = UIButton(type: .system)
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
|
||||||
@ -58,11 +55,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
desc: "拍完后分享给用户,用户在小程序上选择性购买",
|
desc: "拍完后分享给用户,用户在小程序上选择性购买",
|
||||||
selected: true
|
selected: true
|
||||||
)
|
)
|
||||||
preOrderCard.apply(
|
|
||||||
title: "买了再拍",
|
|
||||||
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
|
|
||||||
selected: false
|
|
||||||
)
|
|
||||||
|
|
||||||
fieldsStack.axis = .vertical
|
fieldsStack.axis = .vertical
|
||||||
fieldsStack.spacing = 14
|
fieldsStack.spacing = 14
|
||||||
@ -71,23 +63,12 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
|
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
|
||||||
configureTextField(packagePriceField, 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.setConfigurationContentInsets(
|
|
||||||
NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)
|
|
||||||
)
|
|
||||||
|
|
||||||
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
|
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
|
||||||
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
||||||
|
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
scrollView.addSubview(contentView)
|
scrollView.addSubview(contentView)
|
||||||
[titleLabel, preShootCard, preOrderCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview)
|
[titleLabel, preShootCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview)
|
||||||
rebuildFields()
|
rebuildFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,12 +88,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
make.top.equalTo(titleLabel.snp.bottom).offset(18)
|
make.top.equalTo(titleLabel.snp.bottom).offset(18)
|
||||||
make.leading.trailing.equalToSuperview().inset(16)
|
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
|
fieldsStack.snp.makeConstraints { make in
|
||||||
make.top.equalTo(preOrderCard.snp.bottom).offset(18)
|
make.top.equalTo(preShootCard.snp.bottom).offset(18)
|
||||||
make.leading.trailing.equalTo(preShootCard)
|
make.leading.trailing.equalTo(preShootCard)
|
||||||
}
|
}
|
||||||
cancelButton.snp.makeConstraints { make in
|
cancelButton.snp.makeConstraints { make in
|
||||||
@ -130,9 +107,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
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)
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
[freeCountField, singlePriceField, packagePriceField].forEach {
|
[freeCountField, singlePriceField, packagePriceField].forEach {
|
||||||
@ -171,13 +145,9 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
|
|
||||||
private func rebuildFields() {
|
private func rebuildFields() {
|
||||||
fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
if mode == .preShoot {
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
|
||||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
|
||||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
|
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
|
||||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
|
|
||||||
} else {
|
|
||||||
fieldsStack.addArrangedSubview(makeOrderGroup())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
|
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
|
||||||
@ -196,23 +166,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
return container
|
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 {
|
private func fieldTitle(_ text: String, required: Bool) -> NSAttributedString {
|
||||||
let result = NSMutableAttributedString(
|
let result = NSMutableAttributedString(
|
||||||
string: text,
|
string: text,
|
||||||
@ -224,58 +177,6 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
return result
|
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() {
|
@objc private func cancelTapped() {
|
||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
@ -287,7 +188,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
|||||||
freeCount: freeCountField.text ?? "",
|
freeCount: freeCountField.text ?? "",
|
||||||
singlePrice: singlePriceField.text ?? "",
|
singlePrice: singlePriceField.text ?? "",
|
||||||
packagePrice: packagePriceField.text ?? "",
|
packagePrice: packagePriceField.text ?? "",
|
||||||
order: selectedOrder,
|
order: nil,
|
||||||
api: api
|
api: api
|
||||||
)
|
)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
@ -421,13 +421,19 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
let avatarWrap = UIView()
|
let avatarWrap = UIView()
|
||||||
avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12)
|
avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12)
|
||||||
avatarWrap.layer.cornerRadius = 28
|
avatarWrap.layer.cornerRadius = 28
|
||||||
let avatar = UIImageView(image: UIImage(systemName: clue.avatar ?? "person.crop.circle.fill"))
|
let placeholder = UIImage(systemName: "person.crop.circle.fill")
|
||||||
|
let avatar = UIImageView(image: placeholder)
|
||||||
avatar.tintColor = AppColor.success
|
avatar.tintColor = AppColor.success
|
||||||
avatar.contentMode = .scaleAspectFit
|
avatar.contentMode = .scaleAspectFill
|
||||||
|
avatar.clipsToBounds = true
|
||||||
|
avatar.layer.cornerRadius = 28
|
||||||
|
let avatarURLString = clue.avatar?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if let avatarURL = URL(string: avatarURLString), !avatarURLString.isEmpty {
|
||||||
|
avatar.kf.setImage(with: avatarURL, placeholder: placeholder)
|
||||||
|
}
|
||||||
avatarWrap.addSubview(avatar)
|
avatarWrap.addSubview(avatar)
|
||||||
avatar.snp.makeConstraints { make in
|
avatar.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
make.size.equalTo(42)
|
|
||||||
}
|
}
|
||||||
avatarWrap.snp.makeConstraints { make in
|
avatarWrap.snp.makeConstraints { make in
|
||||||
make.size.equalTo(56)
|
make.size.equalTo(56)
|
||||||
|
|||||||
@ -82,7 +82,8 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
TravelAlbumUploadMaterialRequest(
|
TravelAlbumUploadMaterialRequest(
|
||||||
userEquityTravelId: 3,
|
userEquityTravelId: 3,
|
||||||
fileName: "A.JPG",
|
fileName: "A.JPG",
|
||||||
fileUrl: "https://cdn/a.jpg"
|
fileUrl: "https://cdn/a.jpg",
|
||||||
|
clientPhotoId: "2a1d96b1-c0cc-489f-9f42-419ff1439a62"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -94,6 +95,22 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 3)
|
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 3)
|
||||||
XCTAssertEqual(body?["file_name"] as? String, "A.JPG")
|
XCTAssertEqual(body?["file_name"] as? String, "A.JPG")
|
||||||
XCTAssertEqual(body?["file_url"] as? String, "https://cdn/a.jpg")
|
XCTAssertEqual(body?["file_url"] as? String, "https://cdn/a.jpg")
|
||||||
|
XCTAssertEqual(body?["client_photo_id"] as? String, "2a1d96b1-c0cc-489f-9f42-419ff1439a62")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMaterialClientPhotoIdsBuildsPathQueryAndDecodesResponse() async throws {
|
||||||
|
let data = envelopeJSON(#"{"client_photo_ids":["one","two"]}"#)
|
||||||
|
let session = MockURLSession(responses: [data])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.materialClientPhotoIds(userEquityTravelId: 3)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.clientPhotoIds, ["one", "two"])
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "GET")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/material-client-photo-ids")
|
||||||
|
let query = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func envelopeJSON(_ dataJSON: String) -> Data {
|
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||||
|
|||||||
@ -90,6 +90,78 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
|
|||||||
XCTAssertFalse(FileManager.default.fileExists(atPath: another.deletingLastPathComponent().deletingLastPathComponent().path))
|
XCTAssertFalse(FileManager.default.fileExists(atPath: another.deletingLastPathComponent().deletingLastPathComponent().path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testLegacyRecordGetsStableClientPhotoIdOnLoad() throws {
|
||||||
|
let root = try makeTempDirectory()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = makeStore(root: root)
|
||||||
|
let legacyJSON = """
|
||||||
|
{"id":"legacy","sourceId":"legacy","fileName":"A.JPG","localPath":"",\
|
||||||
|
"thumbnailPath":"","capturedAt":"2026-07-16 12:00:00","fileSizeBytes":1,\
|
||||||
|
"status":"UPLOADED","progress":100,"albumId":33,"userId":"u1",\
|
||||||
|
"remoteUrl":"https://cdn/a.jpg","updatedAt":1}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let legacy = try JSONDecoder().decode(TravelAlbumOTGPhotoRecord.self, from: legacyJSON)
|
||||||
|
XCTAssertTrue(legacy.clientPhotoId.isEmpty)
|
||||||
|
store.save([legacy], albumId: 33)
|
||||||
|
|
||||||
|
let first = try XCTUnwrap(store.load(albumId: 33).first?.clientPhotoId)
|
||||||
|
let second = try XCTUnwrap(store.load(albumId: 33).first?.clientPhotoId)
|
||||||
|
|
||||||
|
XCTAssertNotNil(UUID(uuidString: first))
|
||||||
|
XCTAssertEqual(first, first.lowercased())
|
||||||
|
XCTAssertEqual(first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerStatusPolicyPromotesDemotesAndPreservesActiveOrChangedRecords() {
|
||||||
|
let matched = makeStatusRecord(id: "matched", clientPhotoId: "server-id", status: .failed)
|
||||||
|
let missing = makeStatusRecord(id: "missing", clientPhotoId: "missing-id", status: .uploaded)
|
||||||
|
let active = makeStatusRecord(id: "active", clientPhotoId: "active-id", status: .uploaded)
|
||||||
|
let changedBaseline = makeStatusRecord(id: "changed", clientPhotoId: "changed-id", status: .uploaded)
|
||||||
|
var changed = changedBaseline
|
||||||
|
changed.progress = 37
|
||||||
|
|
||||||
|
let result = TravelAlbumOTGServerStatusPolicy.reconcile(
|
||||||
|
records: [matched, missing, active, changed],
|
||||||
|
baselineRecordsById: [
|
||||||
|
"matched": matched,
|
||||||
|
"missing": missing,
|
||||||
|
"active": active,
|
||||||
|
"changed": changedBaseline,
|
||||||
|
],
|
||||||
|
activePhotoIds: ["active"],
|
||||||
|
serverClientPhotoIds: [" SERVER-ID "],
|
||||||
|
now: 9
|
||||||
|
)
|
||||||
|
let byId = Dictionary(uniqueKeysWithValues: result.map { ($0.id, $0) })
|
||||||
|
|
||||||
|
XCTAssertEqual(byId["matched"]?.status, .uploaded)
|
||||||
|
XCTAssertEqual(byId["matched"]?.progress, 100)
|
||||||
|
XCTAssertEqual(byId["missing"]?.status, .pending)
|
||||||
|
XCTAssertEqual(byId["active"]?.status, .uploaded)
|
||||||
|
XCTAssertEqual(byId["changed"]?.status, .uploaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStatusRecord(
|
||||||
|
id: String,
|
||||||
|
clientPhotoId: String,
|
||||||
|
status: TravelAlbumOTGUploadStatus
|
||||||
|
) -> TravelAlbumOTGPhotoRecord {
|
||||||
|
TravelAlbumOTGPhotoRecord(
|
||||||
|
id: id,
|
||||||
|
clientPhotoId: clientPhotoId,
|
||||||
|
fileName: "\(id).JPG",
|
||||||
|
localPath: "",
|
||||||
|
capturedAt: "2026-07-16 12:00:00",
|
||||||
|
fileSizeBytes: 1,
|
||||||
|
status: status,
|
||||||
|
progress: status == .uploaded ? 100 : 0,
|
||||||
|
albumId: 33,
|
||||||
|
userId: "u1",
|
||||||
|
remoteUrl: "https://cdn/\(id).jpg",
|
||||||
|
updatedAt: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeStore(root: URL) -> TravelAlbumOTGPhotoStore {
|
private func makeStore(root: URL) -> TravelAlbumOTGPhotoStore {
|
||||||
TravelAlbumOTGPhotoStore(
|
TravelAlbumOTGPhotoStore(
|
||||||
context: TravelAlbumOTGStorageContext(
|
context: TravelAlbumOTGStorageContext(
|
||||||
|
|||||||
@ -483,6 +483,37 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id)))
|
XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id)))
|
||||||
XCTAssertEqual(Set(records.map(\.status)), [.uploaded])
|
XCTAssertEqual(Set(records.map(\.status)), [.uploaded])
|
||||||
XCTAssertEqual(Set(records.map(\.fileName)).count, 2)
|
XCTAssertEqual(Set(records.map(\.fileName)).count, 2)
|
||||||
|
XCTAssertEqual(Set(records.map(\.clientPhotoId)).count, 2)
|
||||||
|
XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async {
|
||||||
|
let context = makeOTGTestContext()
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(
|
||||||
|
clientPhotoIds: ["server-id"]
|
||||||
|
)
|
||||||
|
let records = [
|
||||||
|
makeOTGRecord(id: "matched", capturedAt: "2026-07-16 12:00:00", status: .failed)
|
||||||
|
.withClientPhotoId("server-id"),
|
||||||
|
makeOTGRecord(id: "missing", capturedAt: "2026-07-16 12:01:00", status: .uploaded)
|
||||||
|
.withClientPhotoId("missing-id"),
|
||||||
|
]
|
||||||
|
context.store.save(records, albumId: 9)
|
||||||
|
let viewModel = makeWiredViewModel(
|
||||||
|
context: context,
|
||||||
|
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||||
|
api: api
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.start()
|
||||||
|
await waitUntil {
|
||||||
|
let byId = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
|
||||||
|
return byId["matched"]?.status == .uploaded && byId["missing"]?.status == .pending
|
||||||
|
}
|
||||||
|
viewModel.stop()
|
||||||
|
|
||||||
|
XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -683,6 +714,7 @@ private func makeWiredViewModel(
|
|||||||
context: OTGTestContext,
|
context: OTGTestContext,
|
||||||
manager: MockWiredCameraConnectionManager,
|
manager: MockWiredCameraConnectionManager,
|
||||||
uploader: MockTravelAlbumOTGUploader? = nil,
|
uploader: MockTravelAlbumOTGUploader? = nil,
|
||||||
|
api: TravelAlbumMockAPI? = nil,
|
||||||
userDefaults: UserDefaults = makeOTGTestDefaults()
|
userDefaults: UserDefaults = makeOTGTestDefaults()
|
||||||
) -> WiredCameraTransferViewModel {
|
) -> WiredCameraTransferViewModel {
|
||||||
prepareAppStoreForOTGTests()
|
prepareAppStoreForOTGTests()
|
||||||
@ -693,10 +725,19 @@ private func makeWiredViewModel(
|
|||||||
connectionManager: manager,
|
connectionManager: manager,
|
||||||
storage: context.store,
|
storage: context.store,
|
||||||
uploader: uploader ?? MockTravelAlbumOTGUploader(),
|
uploader: uploader ?? MockTravelAlbumOTGUploader(),
|
||||||
|
api: api ?? TravelAlbumMockAPI(),
|
||||||
userDefaults: userDefaults
|
userDefaults: userDefaults
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension TravelAlbumOTGPhotoRecord {
|
||||||
|
func withClientPhotoId(_ value: String) -> TravelAlbumOTGPhotoRecord {
|
||||||
|
var copy = self
|
||||||
|
copy.clientPhotoId = value
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeOTGPhotoItem(
|
private func makeOTGPhotoItem(
|
||||||
id: String,
|
id: String,
|
||||||
capturedAt: String,
|
capturedAt: String,
|
||||||
@ -914,6 +955,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
var infoResponse = TravelAlbum()
|
var infoResponse = TravelAlbum()
|
||||||
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
||||||
var uploadMaterialResponse = TravelAlbumMaterial()
|
var uploadMaterialResponse = TravelAlbumMaterial()
|
||||||
|
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
|
||||||
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
||||||
var createError: Error?
|
var createError: Error?
|
||||||
|
|
||||||
@ -921,6 +963,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
||||||
private(set) var materialRequests: [MaterialRequest] = []
|
private(set) var materialRequests: [MaterialRequest] = []
|
||||||
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
|
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
|
||||||
|
private(set) var materialClientPhotoIdsCallCount = 0
|
||||||
private(set) var deletedAlbumIds: [Int] = []
|
private(set) var deletedAlbumIds: [Int] = []
|
||||||
private(set) var deletedMaterialIds: [Int] = []
|
private(set) var deletedMaterialIds: [Int] = []
|
||||||
|
|
||||||
@ -968,6 +1011,11 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
return uploadMaterialResponse
|
return uploadMaterialResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func materialClientPhotoIds(userEquityTravelId: Int) async throws -> TravelAlbumMaterialClientPhotoIDsResponse {
|
||||||
|
materialClientPhotoIdsCallCount += 1
|
||||||
|
return materialClientPhotoIdsResponse
|
||||||
|
}
|
||||||
|
|
||||||
func deleteAlbum(id: Int) async throws {
|
func deleteAlbum(id: Int) async throws {
|
||||||
deletedAlbumIds.append(id)
|
deletedAlbumIds.append(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -505,6 +505,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(queryItems["latitude"], "43.3779")
|
XCTAssertEqual(queryItems["latitude"], "43.3779")
|
||||||
XCTAssertEqual(queryItems["longitude"], "84.0217")
|
XCTAssertEqual(queryItems["longitude"], "84.0217")
|
||||||
XCTAssertEqual(response.name, "阿依达娜")
|
XCTAssertEqual(response.name, "阿依达娜")
|
||||||
|
XCTAssertEqual(response.avatar, "https://example.com/avatar.jpg")
|
||||||
XCTAssertEqual(response.storeName, "空中草原旅拍店")
|
XCTAssertEqual(response.storeName, "空中草原旅拍店")
|
||||||
XCTAssertTrue(response.online)
|
XCTAssertTrue(response.online)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user