修复有线传图新拍照片不显示,并优化旅拍相册列表交互。
未绑定相册的新拍照片被错误过滤导致列表始终为空;同时调整入口卡片点击区域与详情素材网格布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
| 页面 | 说明 |
|
| 页面 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口 |
|
| `TravelAlbumEntryView` | 新增相册列表、新建任务入口;卡片主区域点击进入详情,底部保留「拍传」「相册码」 |
|
||||||
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
|
| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 |
|
||||||
| `TravelAlbumDetailView` | 相册详情与素材管理 |
|
| `TravelAlbumDetailView` | 相册详情与素材管理 |
|
||||||
| `WiredCameraTransferView` | 有线传图页 |
|
| `WiredCameraTransferView` | 有线传图页 |
|
||||||
|
|||||||
@ -365,7 +365,6 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
private func applyPipelineTasks(_ tasks: [CameraTransferTask]) {
|
private func applyPipelineTasks(_ tasks: [CameraTransferTask]) {
|
||||||
guard let photoStore, let userIDProvider else { return }
|
guard let photoStore, let userIDProvider else { return }
|
||||||
let userID = userIDProvider()
|
let userID = userIDProvider()
|
||||||
guard !userID.isEmpty else { return }
|
|
||||||
|
|
||||||
var merged = photos.filter { photo in
|
var merged = photos.filter { photo in
|
||||||
!tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName }
|
!tasks.contains { $0.assetID == photo.id || $0.filename == photo.fileName }
|
||||||
@ -406,6 +405,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
merged.insert(item, at: 0)
|
merged.insert(item, at: 0)
|
||||||
sessionNewPhotoIDs.insert(task.assetID)
|
sessionNewPhotoIDs.insert(task.assetID)
|
||||||
|
|
||||||
|
guard !userID.isEmpty else { continue }
|
||||||
let record = item.toRecord(
|
let record = item.toRecord(
|
||||||
albumId: context.albumId,
|
albumId: context.albumId,
|
||||||
userId: userID,
|
userId: userID,
|
||||||
@ -422,11 +422,12 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
photos = merged.sorted { $0.capturedAt > $1.capturedAt }
|
photos = merged.sorted { $0.capturedAt > $1.capturedAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断照片是否归属当前相册;未绑定的会话新拍默认归属当前页。
|
||||||
private func belongsToCurrentAlbum(_ photoID: String) -> Bool {
|
private func belongsToCurrentAlbum(_ photoID: String) -> Bool {
|
||||||
if let bound = photoStore?.albumID(forPhoto: photoID) {
|
guard let bound = photoStore?.albumID(forPhoto: photoID) else {
|
||||||
return bound == context.albumId
|
return true
|
||||||
}
|
}
|
||||||
return sessionNewPhotoIDs.contains(photoID)
|
return bound == context.albumId
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let captureTimestampFormatter: DateFormatter = {
|
private static let captureTimestampFormatter: DateFormatter = {
|
||||||
|
|||||||
@ -119,11 +119,13 @@ struct TravelAlbumDetailView: View {
|
|||||||
|
|
||||||
private func materialCell(_ material: TravelAlbumMaterial) -> some View {
|
private func materialCell(_ material: TravelAlbumMaterial) -> some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
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)
|
Color(hex: 0xE5E7EB)
|
||||||
}
|
}
|
||||||
.aspectRatio(1, contentMode: .fill)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@ -142,6 +144,9 @@ struct TravelAlbumDetailView: View {
|
|||||||
.padding(4)
|
.padding(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteAlbum() async {
|
private func deleteAlbum() async {
|
||||||
|
|||||||
@ -136,32 +136,54 @@ private struct TravelAlbumCard: View {
|
|||||||
let onOpenCode: () -> Void
|
let onOpenCode: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
NavigationLink {
|
||||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
|
TravelAlbumDetailView(albumID: album.id)
|
||||||
RemoteImage(urlString: album.coverURL) {
|
.onAppear { onOpenDetail() }
|
||||||
Color(hex: 0xE5E7EB)
|
} label: {
|
||||||
.overlay { Image(systemName: "photo").foregroundStyle(AppDesign.placeholder) }
|
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)
|
.frame(width: 72, height: 72)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(album.name)
|
Text(album.name)
|
||||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||||
.foregroundStyle(AppDesign.textPrimary)
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if !album.displayPhone.isEmpty {
|
if !album.displayPhone.isEmpty {
|
||||||
Label(album.displayPhone, systemImage: "phone.fill")
|
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))
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
.foregroundStyle(AppDesign.textSecondary)
|
.foregroundStyle(AppDesign.primary)
|
||||||
}
|
}
|
||||||
Text(album.type == 1 ? "先拍再买" : "买了再拍")
|
Spacer()
|
||||||
.font(.system(size: AppMetrics.FontSize.caption))
|
Image(systemName: "chevron.right")
|
||||||
.foregroundStyle(AppDesign.primary)
|
.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) {
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
WiredCameraTransferView(context: WiredTransferContext(
|
WiredCameraTransferView(context: WiredTransferContext(
|
||||||
@ -178,18 +200,10 @@ private struct TravelAlbumCard: View {
|
|||||||
Button(action: onOpenCode) {
|
Button(action: onOpenCode) {
|
||||||
actionChip(title: "相册码", systemImage: "qrcode")
|
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 {
|
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