修复有线传图新拍照片不显示,并优化旅拍相册列表交互。
未绑定相册的新拍照片被错误过滤导致列表始终为空;同时调整入口卡片点击区域与详情素材网格布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -13,7 +13,7 @@
|
||||
|
||||
| 页面 | 说明 |
|
||||
| --- | --- |
|
||||
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口 |
|
||||
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 |
|
||||
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
|
||||
| `TravelAlbumDetailView` | 相册详情与素材管理 |
|
||||
| `WiredCameraTransferView` | 有线传图页 |
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<TravelAlbumItem> {
|
||||
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<TravelAlbumMaterial> {
|
||||
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<TravelAlbumMaterial>.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 { "" }
|
||||
}
|
||||
Reference in New Issue
Block a user