对齐旅拍相册详情页 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

@ -81,6 +81,139 @@ final class TravelAlbumViewModelTests: XCTestCase {
)
XCTAssertTrue(name.hasSuffix("003"))
}
/// Tab is_purchased
func testDetailSelectPurchasedTabUsesPurchasedFilter() async {
let viewModel = TravelAlbumDetailViewModel()
let api = MockTravelAlbumDetailAPI()
await viewModel.refreshAll(api: api, albumID: 8)
viewModel.isSelectionMode = true
viewModel.selectedMaterialIDs = [1]
await viewModel.selectTab(TravelAlbumDetailViewModel.tabPurchased, api: api)
XCTAssertEqual(viewModel.selectedTab, TravelAlbumDetailViewModel.tabPurchased)
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
XCTAssertEqual(api.materialListCalls.last?.isPurchased, 1)
}
///
func testDetailSetOrderByReloadsMaterials() async {
let viewModel = TravelAlbumDetailViewModel()
let api = MockTravelAlbumDetailAPI()
await viewModel.refreshAll(api: api, albumID: 8)
let initialCount = api.materialListCalls.count
await viewModel.setOrderBy(3, api: api)
XCTAssertEqual(viewModel.orderBy, 3)
XCTAssertGreaterThan(api.materialListCalls.count, initialCount)
XCTAssertEqual(api.materialListCalls.last?.orderBy, 3)
}
///
func testDetailToggleSelectionRejectsPurchasedMaterial() async {
let viewModel = TravelAlbumDetailViewModel()
viewModel.isSelectionMode = true
let purchased = try! decodeMaterial(id: 2, status: 2)
viewModel.toggleMaterialSelection(purchased)
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
XCTAssertEqual(viewModel.errorMessage, "仅未购买素材可删除")
let unpurchased = try! decodeMaterial(id: 3, status: TravelAlbumDetailViewModel.materialStatusUnpurchased)
viewModel.toggleMaterialSelection(unpurchased)
XCTAssertEqual(viewModel.selectedMaterialIDs, [3])
}
/// API
func testDetailConfirmDeleteSelectedMaterialsDeletesInOrder() async {
let viewModel = TravelAlbumDetailViewModel()
let api = MockTravelAlbumDetailAPI()
viewModel.selectedMaterialIDs = [11, 12, 13]
let success = await viewModel.confirmDeleteSelectedMaterials(api: api)
XCTAssertTrue(success)
XCTAssertEqual(Set(api.deletedMaterialIDs), Set([11, 12, 13]))
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
}
/// Tab allPhotoCount total
func testDetailAllPhotoCountSyncsWithTotalOnAllTab() async {
let viewModel = TravelAlbumDetailViewModel()
let api = MockTravelAlbumDetailAPI(materialTotal: 42)
await viewModel.refreshAll(api: api, albumID: 8)
XCTAssertEqual(viewModel.allPhotoCount, 42)
}
}
private func decodeMaterial(id: Int, status: Int) throws -> TravelAlbumMaterial {
let json = """
{"code":100000,"msg":"success","data":{"id":"\(id)","user_equity_travel_id":"8","status":"\(status)","order_number":"","user_id":"100","file_name":"DSC.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}}
"""
let data = Data(json.utf8)
let envelope = try JSONDecoder().decode(APIEnvelope<TravelAlbumMaterial>.self, from: data)
return try XCTUnwrap(envelope.data)
}
@MainActor
private final class MockTravelAlbumDetailAPI: TravelAlbumServing {
struct MaterialListCall {
let orderBy: Int
let isPurchased: Int?
let page: Int
}
var materialTotal: Int
var materialListCalls: [MaterialListCall] = []
var deletedMaterialIDs: [Int] = []
init(materialTotal: Int = 5) {
self.materialTotal = materialTotal
}
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }
func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
TravelAlbumCreateResponse(id: 1)
}
func albumList(page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumItem> {
ListPayload(total: 0, list: [])
}
func albumInfo(id: Int) async throws -> TravelAlbumItem {
let data = Data(
#"{"code":100000,"msg":"success","data":{"id":"8","store_user_id":"1","name":"2026-06-30-001","type":"1","order_number":"","material_num":"3","material_price":"990","material_package_price":"9900","photo_price":"0","cover_url":"","user_id":"100","status":"1","created_at":"2026-06-30","updated_at":"2026-06-30","user":{"id":"200","phone":"13800138000"}}}"#.utf8
)
let envelope = try JSONDecoder().decode(APIEnvelope<TravelAlbumItem>.self, from: data)
return try XCTUnwrap(envelope.data)
}
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
func materialList(
userEquityTravelId: Int,
page: Int,
pageSize: Int,
orderBy: Int,
isPurchased: Int?
) async throws -> ListPayload<TravelAlbumMaterial> {
materialListCalls.append(MaterialListCall(orderBy: orderBy, isPurchased: isPurchased, page: page))
return ListPayload(total: materialTotal, list: [])
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
throw APIError.emptyData
}
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {
deletedMaterialIDs.append(request.id)
}
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
TravelAlbumMpCodeResponse(mpCodeOssURL: "")
}
}
@MainActor
@ -108,7 +241,13 @@ private final class MockTravelAlbumAPI: TravelAlbumServing {
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: [])
}