优化有线传图页设备展示,并缓存传输模式选项。
未连接状态改为「未连接相机」;设备型号映射为可读名称而非 iPhone12,1 等内部代号;边拍边传/拍后传输按账号持久化并在下次进入时恢复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -27,19 +27,7 @@ enum DeviceStorageHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func buildModelName() -> String {
|
private static func buildModelName() -> String {
|
||||||
var systemInfo = utsname()
|
DeviceModelNameResolver.marketingName()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func availableDocumentsBytes() -> Int64 {
|
private static func availableDocumentsBytes() -> Int64 {
|
||||||
|
|||||||
@ -16,6 +16,31 @@ final class WiredTransferPhotoStore {
|
|||||||
private static let keyPrefix = "wired_transfer_photos_"
|
private static let keyPrefix = "wired_transfer_photos_"
|
||||||
private static let deletedSuffix = "_deleted"
|
private static let deletedSuffix = "_deleted"
|
||||||
private static let bindingsSuffix = "_photo_bindings"
|
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 提供者。
|
/// 初始化仓库,注入账号前缀与用户 ID 提供者。
|
||||||
init(
|
init(
|
||||||
@ -187,6 +212,10 @@ final class WiredTransferPhotoStore {
|
|||||||
"\(accountPrefixProvider())\(Self.bindingsSuffix)"
|
"\(accountPrefixProvider())\(Self.bindingsSuffix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func transferModeKey() -> String {
|
||||||
|
"\(accountPrefixProvider())\(Self.transferModeKeySuffix)"
|
||||||
|
}
|
||||||
|
|
||||||
private struct PhotoAlbumBinding: Codable {
|
private struct PhotoAlbumBinding: Codable {
|
||||||
let photoID: String
|
let photoID: String
|
||||||
let albumID: Int
|
let albumID: Int
|
||||||
|
|||||||
@ -61,6 +61,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
|||||||
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
|
||||||
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||||
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||||
|
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
||||||
|
|
||||||
## 业务流程
|
## 业务流程
|
||||||
|
|
||||||
|
|||||||
@ -481,6 +481,9 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
userIDProvider: { accountContext.profile?.userId ?? "" }
|
userIDProvider: { accountContext.profile?.userId ?? "" }
|
||||||
)
|
)
|
||||||
userIDProvider = { accountContext.profile?.userId ?? "" }
|
userIDProvider = { accountContext.profile?.userId ?? "" }
|
||||||
|
if let cachedMode = photoStore?.loadTransferModeOption() {
|
||||||
|
transferModeOption = cachedMode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let uploader = TravelAlbumMaterialUploader(
|
let uploader = TravelAlbumMaterialUploader(
|
||||||
api: api,
|
api: api,
|
||||||
@ -542,6 +545,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
|
func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) {
|
||||||
guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
|
guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return }
|
||||||
transferModeOption = option
|
transferModeOption = option
|
||||||
|
photoStore?.saveTransferModeOption(option)
|
||||||
let autoUpload = option == Self.modeLiveCapture
|
let autoUpload = option == Self.modeLiveCapture
|
||||||
let uploader = TravelAlbumMaterialUploader(
|
let uploader = TravelAlbumMaterialUploader(
|
||||||
api: api,
|
api: api,
|
||||||
|
|||||||
@ -115,7 +115,7 @@ struct WiredTransferControlCard: View {
|
|||||||
let name = cameraDeviceName.isEmpty ? "已连接相机" : cameraDeviceName
|
let name = cameraDeviceName.isEmpty ? "已连接相机" : cameraDeviceName
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
return "未连接 USB"
|
return "未连接相机"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var refreshButton: some View {
|
private var refreshButton: some View {
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -296,6 +296,45 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.photos.first?.status, .uploaded)
|
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 {
|
func testDownloadedPhotoGeneratesThumbnailForList() async throws {
|
||||||
let camera = MockWiredCameraService()
|
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 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 { "" }
|
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 ?? "" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user