对齐旅拍相册详情页 Android UI,并修复网格预览与布局问题。

重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 13:53:59 +08:00
parent c71b45bdfd
commit 5e620623eb
23 changed files with 2000 additions and 288 deletions

View File

@ -6,6 +6,7 @@
//
import XCTest
import UIKit
@testable import suixinkan
@MainActor
@ -15,7 +16,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
func testFirstNewPipelineTaskAppearsInPhotos() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 1,
albumId: 6603,
albumName: "测试相册",
phone: "",
orderNumber: ""
@ -125,10 +126,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
)
viewModel.photos = [
viewModel.replacePhotos([
makePendingPhoto(id: "today", capturedAt: today),
makePendingPhoto(id: "old", capturedAt: "2020-01-01 09:00:00"),
]
])
await viewModel.onSpecifyUploadOptionSelected(
WiredCameraTransferViewModel.specifyUploadTodayCaptured,
@ -139,23 +140,257 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertNil(viewModel.errorMessage)
}
/// capturedAt
func testPipelineProgressUpdatePreservesCapturedAt() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 1,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let originalCapturedAt = "2026-05-20 12:05:18"
viewModel.replacePhotos([
WiredTransferPhotoItem(
id: "ptp_progress",
fileName: "DSC_PROGRESS.JPG",
thumbnailURL: "",
previewURL: "",
capturedAt: originalCapturedAt,
fileSizeText: "2 MB",
status: .uploading,
progress: 10
),
])
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG")
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
camera.downloadURL = fileURL
camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16))
try await Task.sleep(nanoseconds: 300_000_000)
XCTAssertEqual(viewModel.photos.first?.capturedAt, originalCapturedAt)
}
///
func testNewerCapturedPhotoAppearsFirst() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 6604,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let fileURL = FileManager.default.temporaryDirectory
let olderURL = fileURL.appendingPathComponent("OLDER.JPG")
let newerURL = fileURL.appendingPathComponent("NEWER.JPG")
try Data(repeating: 0xAA, count: 8).write(to: olderURL)
try Data(repeating: 0xBB, count: 8).write(to: newerURL)
defer {
try? FileManager.default.removeItem(at: olderURL)
try? FileManager.default.removeItem(at: newerURL)
}
camera.downloadURL = olderURL
camera.onNewAsset?(
CameraAsset(
id: "ptp_old",
filename: "OLDER.JPG",
fileSize: 8,
creationDate: Date(timeIntervalSince1970: 1_700_000_000)
)
)
try await Task.sleep(nanoseconds: 400_000_000)
camera.downloadURL = newerURL
camera.onNewAsset?(
CameraAsset(
id: "ptp_new",
filename: "NEWER.JPG",
fileSize: 8,
creationDate: Date(timeIntervalSince1970: 1_800_000_000)
)
)
try await Task.sleep(nanoseconds: 400_000_000)
XCTAssertEqual(viewModel.photos.count, 2)
XCTAssertEqual(viewModel.photos[0].id, "ptp_new")
XCTAssertEqual(viewModel.photos[1].id, "ptp_old")
}
///
func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 1,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_failed.JPG")
try Data(repeating: 0xCD, count: 32).write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
let photoID = "persist_failed_001"
let record = WiredTransferPhotoRecord(
id: photoID,
sourceId: photoID,
fileName: "retry_failed.JPG",
localPath: CameraDownloadStorage.relativePath(for: fileURL),
thumbnailPath: "",
capturedAt: "2026-05-20 12:05:18",
fileSizeBytes: 32,
status: WiredTransferUploadStatus.failed.rawValue,
progress: 0,
errorMessage: "upload failed",
albumId: 1,
userId: "101",
remoteURL: "",
updatedAt: Date().timeIntervalSince1970
)
let store = WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { "101" }
)
store.save(albumID: 1, records: [record])
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
viewModel.transferModeOption = WiredCameraTransferViewModel.modePostShootTransfer
let api = MockTravelAlbumAPIForWiredTransfer()
let oss = WiredTransferMockOSSUploadService()
await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account)
XCTAssertEqual(viewModel.photos.first?.id, photoID)
XCTAssertEqual(viewModel.photos.first?.status, .failed)
await viewModel.retryPhoto(id: photoID, api: api, ossService: oss, scenicID: 1)
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(viewModel.photos.first?.status, .uploaded)
}
///
func testDownloadedPhotoGeneratesThumbnailForList() async throws {
let camera = MockWiredCameraService()
let albumID = 6601
let context = WiredTransferContext(albumId: albumID, albumName: "缩略图相册", phone: "", orderNumber: "")
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "thumb-user", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG")
try makeJPEGData().write(to: imageURL)
defer { try? FileManager.default.removeItem(at: imageURL) }
camera.downloadURL = imageURL
camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128))
try await Task.sleep(nanoseconds: 900_000_000)
let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" })
XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/Thumbnails/"))
XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/"))
XCTAssertFalse(photo.previewURL.contains("/Thumbnails/"))
}
///
func testLegacyRecordWithoutThumbnailKeepsPreviewURL() async throws {
let albumID = 6602
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "legacy-user", displayName: "测试"))
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy_only.JPG")
try makeJPEGData().write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
let record = WiredTransferPhotoRecord(
id: "legacy_photo_001",
sourceId: "legacy_photo_001",
fileName: "legacy_only.JPG",
localPath: CameraDownloadStorage.relativePath(for: fileURL),
thumbnailPath: "",
capturedAt: "2026-05-20 12:05:18",
fileSizeBytes: 128,
status: WiredTransferUploadStatus.pending.rawValue,
progress: 0,
errorMessage: nil,
albumId: albumID,
userId: "legacy-user",
remoteURL: "",
updatedAt: Date().timeIntervalSince1970
)
let store = WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
userIDProvider: { "legacy-user" }
)
store.save(albumID: albumID, records: [record])
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
)
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let photo = try XCTUnwrap(viewModel.photos.first)
XCTAssertEqual(photo.thumbnailURL, "")
XCTAssertFalse(photo.previewURL.isEmpty)
}
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
)
viewModel.photos = [
viewModel.replacePhotos([
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
makePendingPhoto(id: "pending-2", capturedAt: "2026-05-20 12:18:06"),
WiredTransferPhotoItem(
id: "uploaded-1",
fileName: "uploaded.JPG",
thumbnailURL: "",
previewURL: "",
capturedAt: "2026-05-20 12:20:00",
fileSizeText: "1 MB",
status: .uploaded
),
]
])
return viewModel
}
@ -169,6 +404,14 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
status: .pending
)
}
private func makeJPEGData() -> Data {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8))
return renderer.jpegData(withCompressionQuality: 0.9) { context in
UIColor.systemBlue.setFill()
context.fill(CGRect(x: 0, y: 0, width: 8, height: 8))
}
}
}
@MainActor
@ -210,7 +453,13 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
func albumInfo(id: Int) async throws -> TravelAlbumItem { throw APIError.emptyData }
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumMaterial> {
func materialList(
userEquityTravelId: Int,
page: Int,
pageSize: Int,
orderBy: Int,
isPurchased: Int?
) async throws -> ListPayload<TravelAlbumMaterial> {
ListPayload(total: 0, list: [])
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {