From e91a46c3150f84f5453af16c273edf9ca6e4d3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 30 Jun 2026 10:20:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=89=E7=BA=BF=E4=BC=A0?= =?UTF-8?q?=E5=9B=BE=E6=96=B0=E6=8B=8D=E7=85=A7=E7=89=87=E4=B8=8D=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96=E6=97=85=E6=8B=8D?= =?UTF-8?q?=E7=9B=B8=E5=86=8C=E5=88=97=E8=A1=A8=E4=BA=A4=E4=BA=92=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未绑定相册的新拍照片被错误过滤导致列表始终为空;同时调整入口卡片点击区域与详情素材网格布局。 Co-authored-by: Cursor --- suixinkan/Features/TravelAlbum/TravelAlbum.md | 2 +- .../ViewModels/TravelAlbumViewModels.swift | 9 +- .../Views/TravelAlbumDetailView.swift | 11 +- .../Views/TravelAlbumEntryView.swift | 72 ++++---- .../WiredCameraTransferViewModelTests.swift | 156 ++++++++++++++++++ 5 files changed, 213 insertions(+), 37 deletions(-) create mode 100644 suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 4ec8dfb..8eac320 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -13,7 +13,7 @@ | 页面 | 说明 | | --- | --- | -| `TravelAlbumEntryView` | 新增相册列表、新建任务入口 | +| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 | | `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 | | `TravelAlbumDetailView` | 相册详情与素材管理 | | `WiredCameraTransferView` | 有线传图页 | diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index 1615a55..f16f69f 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -365,7 +365,6 @@ final class WiredCameraTransferViewModel: ObservableObject { private func applyPipelineTasks(_ tasks: [CameraTransferTask]) { guard let photoStore, let userIDProvider else { return } let userID = userIDProvider() - guard !userID.isEmpty else { return } var merged = photos.filter { photo in !tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName } @@ -406,6 +405,7 @@ final class WiredCameraTransferViewModel: ObservableObject { merged.insert(item, at: 0) sessionNewPhotoIDs.insert(task.assetID) + guard !userID.isEmpty else { continue } let record = item.toRecord( albumId: context.albumId, userId: userID, @@ -422,11 +422,12 @@ final class WiredCameraTransferViewModel: ObservableObject { photos = merged.sorted { $0.capturedAt > $1.capturedAt } } + /// 判断照片是否归属当前相册;未绑定的会话新拍默认归属当前页。 private func belongsToCurrentAlbum(_ photoID: String) -> Bool { - if let bound = photoStore?.albumID(forPhoto: photoID) { - return bound == context.albumId + guard let bound = photoStore?.albumID(forPhoto: photoID) else { + return true } - return sessionNewPhotoIDs.contains(photoID) + return bound == context.albumId } private static let captureTimestampFormatter: DateFormatter = { diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift index 814fec7..55cda6d 100644 --- a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift @@ -119,11 +119,13 @@ struct TravelAlbumDetailView: View { private func materialCell(_ material: TravelAlbumMaterial) -> some View { ZStack(alignment: .topTrailing) { - RemoteImage(urlString: material.coverURL.isEmpty ? material.fileURL : material.coverURL) { + RemoteImage( + urlString: material.coverURL.isEmpty ? material.fileURL : material.coverURL, + contentMode: .fill + ) { Color(hex: 0xE5E7EB) } - .aspectRatio(1, contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(maxWidth: .infinity, maxHeight: .infinity) Menu { Button(role: .destructive) { @@ -142,6 +144,9 @@ struct TravelAlbumDetailView: View { .padding(4) } } + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 8)) } private func deleteAlbum() async { diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift index c252ca6..0301f74 100644 --- a/suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift @@ -136,32 +136,54 @@ private struct TravelAlbumCard: View { let onOpenCode: () -> Void var body: some View { - VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { - HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) { - RemoteImage(urlString: album.coverURL) { - Color(hex: 0xE5E7EB) - .overlay { Image(systemName: "photo").foregroundStyle(AppDesign.placeholder) } - } + NavigationLink { + TravelAlbumDetailView(albumID: album.id) + .onAppear { onOpenDetail() } + } label: { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) { + RemoteImage(urlString: album.coverURL) { + Color(hex: 0xE5E7EB) + .overlay { Image(systemName: "photo").foregroundStyle(AppDesign.placeholder) } + } .frame(width: 72, height: 72) .clipShape(RoundedRectangle(cornerRadius: 8)) - VStack(alignment: .leading, spacing: 6) { - Text(album.name) - .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) - .foregroundStyle(AppDesign.textPrimary) - .lineLimit(1) - if !album.displayPhone.isEmpty { - Label(album.displayPhone, systemImage: "phone.fill") + VStack(alignment: .leading, spacing: 6) { + Text(album.name) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + if !album.displayPhone.isEmpty { + Label(album.displayPhone, systemImage: "phone.fill") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Text(album.type == 1 ? "先拍再买" : "买了再拍") .font(.system(size: AppMetrics.FontSize.caption)) - .foregroundStyle(AppDesign.textSecondary) + .foregroundStyle(AppDesign.primary) } - Text(album.type == 1 ? "先拍再买" : "买了再拍") - .font(.system(size: AppMetrics.FontSize.caption)) - .foregroundStyle(AppDesign.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.placeholder) } - Spacer() - } + // 占位底部操作区高度,保证整卡可点进详情且布局与真实按钮对齐 + HStack(spacing: AppMetrics.Spacing.small) { + actionChip(title: "拍传", systemImage: "cable.connector") + actionChip(title: "相册码", systemImage: "qrcode") + } + .hidden() + } + .padding(AppMetrics.Spacing.large) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .overlay(alignment: .bottomLeading) { HStack(spacing: AppMetrics.Spacing.small) { NavigationLink { WiredCameraTransferView(context: WiredTransferContext( @@ -178,18 +200,10 @@ private struct TravelAlbumCard: View { Button(action: onOpenCode) { actionChip(title: "相册码", systemImage: "qrcode") } - - NavigationLink { - TravelAlbumDetailView(albumID: album.id) - .onAppear { onOpenDetail() } - } label: { - actionChip(title: "详情", systemImage: "info.circle") - } } + .padding(.horizontal, AppMetrics.Spacing.large) + .padding(.bottom, AppMetrics.Spacing.large) } - .padding(AppMetrics.Spacing.large) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private func actionChip(title: String, systemImage: String) -> some View { diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift new file mode 100644 index 0000000..47ff28e --- /dev/null +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -0,0 +1,156 @@ +// +// WiredCameraTransferViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/30. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 有线传图 ViewModel 测试。 +final class WiredCameraTransferViewModelTests: XCTestCase { + /// 测试相机管道回调的首张新照片应出现在列表中。 + func testFirstNewPipelineTaskAppearsInPhotos() 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 tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_NEW.JPG") + try Data(repeating: 0xFF, count: 16).write(to: tempURL) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let asset = CameraAsset(id: "ptp_new_001", filename: "DSC_NEW.JPG", fileSize: 16) + camera.downloadURL = tempURL + camera.onNewAsset?(asset) + try await Task.sleep(nanoseconds: 500_000_000) + + XCTAssertEqual(viewModel.photos.count, 1) + XCTAssertEqual(viewModel.photos.first?.id, "ptp_new_001") + XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG") + } + + /// 测试绑定到其他相册的照片不会出现在当前相册列表。 + func testPhotoBoundToOtherAlbumIsFilteredOut() async throws { + let bindingStore = WiredTransferPhotoStore( + accountPrefixProvider: { "user_101" }, + userIDProvider: { "101" } + ) + bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 99) + defer { bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 1) } + + 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: "测试"), accountType: "user") + + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) + + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_OTHER.JPG") + try Data(repeating: 0xAA, count: 8).write(to: tempURL) + defer { try? FileManager.default.removeItem(at: tempURL) } + + camera.downloadURL = tempURL + camera.onNewAsset?(CameraAsset(id: "ptp_other_album", filename: "DSC_OTHER.JPG", fileSize: 8)) + try await Task.sleep(nanoseconds: 500_000_000) + + XCTAssertTrue(viewModel.photos.isEmpty) + } +} + +@MainActor +private final class MockWiredCameraService: CameraServiceProtocol { + let brand: CameraBrand = .sony + var connectionState: CameraConnectionState = .connected(deviceName: "Mock") + var onConnectionStateChange: ((CameraConnectionState) -> Void)? + var onNewAsset: ((CameraAsset) -> Void)? + var downloadURL: URL? + + func connect() async {} + func disconnect() async {} + + func listAssets() async throws -> [CameraAsset] { + [] + } + + func downloadAsset(_ asset: CameraAsset) async throws -> URL { + if let downloadURL { + return downloadURL + } + let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) + if !FileManager.default.fileExists(atPath: url.path) { + try Data(repeating: 0xFF, count: 8).write(to: url) + } + return url + } +} + +@MainActor +private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing { + func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] } + func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse { + TravelAlbumCreateResponse(id: 1) + } + func albumList(page: Int, pageSize: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + 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 { + ListPayload(total: 0, list: []) + } + func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { + let data = Data( + #"{"code":100000,"msg":"success","data":{"id":"11","user_equity_travel_id":"8","status":"1","order_number":"","user_id":"100","file_name":"DSC_001.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}}"#.utf8 + ) + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) + return try XCTUnwrap(envelope.data) + } + func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {} + func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { + TravelAlbumMpCodeResponse(mpCodeOssURL: "") + } +} + +@MainActor +private final class WiredTransferMockOSSUploadService: OSSUploadServing { + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + onProgress(100) + return "https://cdn/mock/\(fileName)" + } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +}