修复有线传图新拍照片不显示,并优化旅拍相册列表交互。

未绑定相册的新拍照片被错误过滤导致列表始终为空;同时调整入口卡片点击区域与详情素材网格布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 10:20:26 +08:00
parent 6769be60e4
commit e91a46c315
5 changed files with 213 additions and 37 deletions

View File

@ -13,7 +13,7 @@
| 页面 | 说明 |
| --- | --- |
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口 |
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 |
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
| `TravelAlbumDetailView` | 相册详情与素材管理 |
| `WiredCameraTransferView` | 有线传图页 |

View File

@ -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 = {

View File

@ -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 {

View File

@ -136,6 +136,10 @@ private struct TravelAlbumCard: View {
let onOpenCode: () -> Void
var body: some View {
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) {
@ -160,8 +164,26 @@ private struct TravelAlbumCard: View {
.foregroundStyle(AppDesign.primary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.placeholder)
}
//
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,19 +200,11 @@ 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 {
HStack(spacing: 4) {

View File

@ -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 { "" }
}