From ceac8ba67e3603a7ec43525bf1ce64094275482e 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 14:07:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9C=89=E7=BA=BF=E4=BC=A0?= =?UTF-8?q?=E5=9B=BE=E9=A1=B5=E8=AE=BE=E5=A4=87=E5=B1=95=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E7=BC=93=E5=AD=98=E4=BC=A0=E8=BE=93=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E9=80=89=E9=A1=B9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未连接状态改为「未连接相机」;设备型号映射为可读名称而非 iPhone12,1 等内部代号;边拍边传/拍后传输按账号持久化并在下次进入时恢复。 Co-authored-by: Cursor --- .../Services/DeviceModelNameResolver.swift | 120 ++++++++++++++++++ .../Services/DeviceStorageHelper.swift | 14 +- .../Services/WiredTransferPhotoStore.swift | 29 +++++ suixinkan/Features/TravelAlbum/TravelAlbum.md | 1 + .../ViewModels/TravelAlbumViewModels.swift | 4 + .../WiredTransferControlCard.swift | 2 +- .../DeviceModelNameResolverTests.swift | 24 ++++ .../WiredCameraTransferViewModelTests.swift | 54 ++++++++ 8 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 suixinkan/Features/TravelAlbum/Services/DeviceModelNameResolver.swift create mode 100644 suixinkanTests/TravelAlbum/DeviceModelNameResolverTests.swift diff --git a/suixinkan/Features/TravelAlbum/Services/DeviceModelNameResolver.swift b/suixinkan/Features/TravelAlbum/Services/DeviceModelNameResolver.swift new file mode 100644 index 0000000..0b3ddd8 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/DeviceModelNameResolver.swift @@ -0,0 +1,120 @@ +// +// DeviceModelNameResolver.swift +// suixinkan +// +// 将 utsname 硬件标识符映射为用户可读的设备型号名称。 +// + +import Foundation +import UIKit + +/// 设备硬件标识符到市场型号名称的解析器。 +enum DeviceModelNameResolver { + /// 返回当前设备的可读型号名称,如「iPhone X」。 + static func marketingName() -> String { + marketingName(forHardwareIdentifier: hardwareIdentifier()) + } + + /// 将硬件标识符解析为可读型号名称。 + static func marketingName(forHardwareIdentifier identifier: String) -> String { + if let name = identifierMap[identifier] { + return name + } + return fallbackName(for: identifier) + } + + /// 读取当前设备硬件标识符,模拟器优先使用 SIMULATOR_MODEL_IDENTIFIER。 + static func hardwareIdentifier() -> String { + #if targetEnvironment(simulator) + if let simulatorIdentifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"], + !simulatorIdentifier.isEmpty { + return simulatorIdentifier + } + #endif + + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(cString: $0) + } + } + } + + /// 未收录的新机型回退为「iPhone / iPad / iPod touch」,避免展示 iPhone12,1 等内部代号。 + private static func fallbackName(for identifier: String) -> String { + if identifier.hasPrefix("iPhone") { + return "iPhone" + } + if identifier.hasPrefix("iPad") { + return "iPad" + } + if identifier.hasPrefix("iPod") { + return "iPod touch" + } + let localized = UIDevice.current.localizedModel.trimmingCharacters(in: .whitespacesAndNewlines) + return localized.isEmpty ? "iPhone" : localized + } + + /// Apple 硬件标识符与市场型号对照表。 + private static let identifierMap: [String: String] = [ + // iPhone + "iPhone9,1": "iPhone 7", + "iPhone9,3": "iPhone 7", + "iPhone9,2": "iPhone 7 Plus", + "iPhone9,4": "iPhone 7 Plus", + "iPhone10,1": "iPhone 8", + "iPhone10,4": "iPhone 8", + "iPhone10,2": "iPhone 8 Plus", + "iPhone10,5": "iPhone 8 Plus", + "iPhone10,3": "iPhone X", + "iPhone10,6": "iPhone X", + "iPhone11,2": "iPhone XS", + "iPhone11,4": "iPhone XS Max", + "iPhone11,6": "iPhone XS Max", + "iPhone11,8": "iPhone XR", + "iPhone12,1": "iPhone 11", + "iPhone12,3": "iPhone 11 Pro", + "iPhone12,5": "iPhone 11 Pro Max", + "iPhone12,8": "iPhone SE (2nd generation)", + "iPhone13,1": "iPhone 12 mini", + "iPhone13,2": "iPhone 12", + "iPhone13,3": "iPhone 12 Pro", + "iPhone13,4": "iPhone 12 Pro Max", + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", + "iPhone14,2": "iPhone 13 Pro", + "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,6": "iPhone SE (3rd generation)", + "iPhone14,7": "iPhone 14", + "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", + "iPhone15,3": "iPhone 14 Pro Max", + "iPhone15,4": "iPhone 15", + "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", + "iPhone16,2": "iPhone 15 Pro Max", + "iPhone17,3": "iPhone 16", + "iPhone17,4": "iPhone 16 Plus", + "iPhone17,1": "iPhone 16 Pro", + "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,5": "iPhone 16e", + // iPad(少量常见型号,避免误连 iPad 时展示内部代号) + "iPad13,18": "iPad (10th generation)", + "iPad13,19": "iPad (10th generation)", + "iPad14,1": "iPad mini (6th generation)", + "iPad14,2": "iPad mini (6th generation)", + "iPad13,1": "iPad Air (4th generation)", + "iPad13,2": "iPad Air (4th generation)", + "iPad13,16": "iPad Air (5th generation)", + "iPad13,17": "iPad Air (5th generation)", + "iPad8,1": "iPad Pro 11-inch", + "iPad8,2": "iPad Pro 11-inch", + "iPad8,3": "iPad Pro 11-inch", + "iPad8,4": "iPad Pro 11-inch", + "iPad8,5": "iPad Pro 12.9-inch", + "iPad8,6": "iPad Pro 12.9-inch", + "iPad8,7": "iPad Pro 12.9-inch", + "iPad8,8": "iPad Pro 12.9-inch" + ] +} diff --git a/suixinkan/Features/TravelAlbum/Services/DeviceStorageHelper.swift b/suixinkan/Features/TravelAlbum/Services/DeviceStorageHelper.swift index 4b65ff4..c77b752 100644 --- a/suixinkan/Features/TravelAlbum/Services/DeviceStorageHelper.swift +++ b/suixinkan/Features/TravelAlbum/Services/DeviceStorageHelper.swift @@ -27,19 +27,7 @@ enum DeviceStorageHelper { } private static func buildModelName() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafePointer(to: &systemInfo.machine) { - $0.withMemoryRebound(to: CChar.self, capacity: 1) { - String(cString: $0) - } - } - let marketingName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) - if !marketingName.isEmpty, marketingName != "iPhone", !marketingName.hasPrefix("iPad") { - return marketingName - } - if !machine.isEmpty { return machine } - return UIDevice.current.model + DeviceModelNameResolver.marketingName() } private static func availableDocumentsBytes() -> Int64 { diff --git a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift index dd25189..e9c9d5b 100644 --- a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift @@ -16,6 +16,31 @@ final class WiredTransferPhotoStore { private static let keyPrefix = "wired_transfer_photos_" private static let deletedSuffix = "_deleted" private static let bindingsSuffix = "_photo_bindings" + private static let transferModeKeySuffix = "wired_transfer_mode_option" + static let modeLiveCapture = "边拍边传" + static let modePostShootTransfer = "拍后传输" + + /// 读取缓存的传输模式选项。 + func loadTransferModeOption() -> String? { + let value = defaults.string(forKey: transferModeKey())? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + switch value { + case Self.modeLiveCapture, Self.modePostShootTransfer: + return value + default: + return nil + } + } + + /// 缓存传输模式选项。 + func saveTransferModeOption(_ option: String) { + switch option { + case Self.modeLiveCapture, Self.modePostShootTransfer: + defaults.set(option, forKey: transferModeKey()) + default: + break + } + } /// 初始化仓库,注入账号前缀与用户 ID 提供者。 init( @@ -187,6 +212,10 @@ final class WiredTransferPhotoStore { "\(accountPrefixProvider())\(Self.bindingsSuffix)" } + private func transferModeKey() -> String { + "\(accountPrefixProvider())\(Self.transferModeKeySuffix)" + } + private struct PhotoAlbumBinding: Codable { let photoID: String let albumID: Int diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 10f0d7b..6721af4 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -61,6 +61,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层 3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」 4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section 5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展 +6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复 ## 业务流程 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index c5a375b..b9e611a 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -481,6 +481,9 @@ final class WiredCameraTransferViewModel: ObservableObject { userIDProvider: { accountContext.profile?.userId ?? "" } ) userIDProvider = { accountContext.profile?.userId ?? "" } + if let cachedMode = photoStore?.loadTransferModeOption() { + transferModeOption = cachedMode + } } let uploader = TravelAlbumMaterialUploader( api: api, @@ -542,6 +545,7 @@ final class WiredCameraTransferViewModel: ObservableObject { func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) { guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return } transferModeOption = option + photoStore?.saveTransferModeOption(option) let autoUpload = option == Self.modeLiveCapture let uploader = TravelAlbumMaterialUploader( api: api, diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift index d5df985..01723f8 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift @@ -115,7 +115,7 @@ struct WiredTransferControlCard: View { let name = cameraDeviceName.isEmpty ? "已连接相机" : cameraDeviceName return name } - return "未连接 USB" + return "未连接相机" } private var refreshButton: some View { diff --git a/suixinkanTests/TravelAlbum/DeviceModelNameResolverTests.swift b/suixinkanTests/TravelAlbum/DeviceModelNameResolverTests.swift new file mode 100644 index 0000000..efc345c --- /dev/null +++ b/suixinkanTests/TravelAlbum/DeviceModelNameResolverTests.swift @@ -0,0 +1,24 @@ +// +// DeviceModelNameResolverTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 设备型号解析测试。 +final class DeviceModelNameResolverTests: XCTestCase { + /// 测试常见硬件标识符能映射为可读型号。 + func testKnownIdentifiersMapToMarketingNames() { + XCTAssertEqual(DeviceModelNameResolver.marketingName(forHardwareIdentifier: "iPhone10,3"), "iPhone X") + XCTAssertEqual(DeviceModelNameResolver.marketingName(forHardwareIdentifier: "iPhone10,6"), "iPhone X") + XCTAssertEqual(DeviceModelNameResolver.marketingName(forHardwareIdentifier: "iPhone12,1"), "iPhone 11") + XCTAssertEqual(DeviceModelNameResolver.marketingName(forHardwareIdentifier: "iPhone16,2"), "iPhone 15 Pro Max") + } + + /// 测试未知标识符不回退为内部代号。 + func testUnknownIdentifierUsesGenericFallback() { + XCTAssertEqual(DeviceModelNameResolver.marketingName(forHardwareIdentifier: "iPhone99,9"), "iPhone") + XCTAssertEqual(DeviceModelNameResolver.marketingName(forHardwareIdentifier: "iPad99,9"), "iPad") + } +} diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index 7e4aa4b..0457c06 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -296,6 +296,45 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(viewModel.photos.first?.status, .uploaded) } + /// 测试切换传输模式后会按账号缓存选项。 + func testSelectTransferModePersistsOption() async { + let account = makeTransferModeTestAccount() + let context = WiredTransferContext(albumId: 7701, albumName: "模式缓存", phone: "", orderNumber: "") + let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService()) + let api = MockTravelAlbumAPIForWiredTransfer() + let oss = WiredTransferMockOSSUploadService() + + await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account) + viewModel.selectTransferMode( + WiredCameraTransferViewModel.modePostShootTransfer, + api: api, + ossService: oss, + scenicID: 1 + ) + + let store = makeTransferModeTestStore(account: account) + XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer) + } + + /// 测试再次进入有线传图页时会恢复上次选择的传输模式。 + func testStartRestoresCachedTransferModeOption() async { + let account = makeTransferModeTestAccount() + let store = makeTransferModeTestStore(account: account) + store.saveTransferModeOption(WiredCameraTransferViewModel.modePostShootTransfer) + + let context = WiredTransferContext(albumId: 7702, albumName: "模式恢复", phone: "", orderNumber: "") + let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService()) + + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) + + XCTAssertEqual(viewModel.transferModeOption, WiredCameraTransferViewModel.modePostShootTransfer) + } + /// 测试下载完成后生成本地缩略图,并优先用于列表展示。 func testDownloadedPhotoGeneratesThumbnailForList() async throws { let camera = MockWiredCameraService() @@ -490,3 +529,18 @@ private final class WiredTransferMockOSSUploadService: OSSUploadServing { 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 { "" } } + +@MainActor +private func makeTransferModeTestAccount() -> AccountContext { + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: "transfer-mode-user", displayName: "测试")) + return account +} + +@MainActor +private func makeTransferModeTestStore(account: AccountContext) -> WiredTransferPhotoStore { + WiredTransferPhotoStore( + accountPrefixProvider: { account.accountCachePrefix ?? "guest_" }, + userIDProvider: { account.profile?.userId ?? "" } + ) +}