diff --git a/suixinkan/App/AppServiceEnvironment.swift b/suixinkan/App/AppServiceEnvironment.swift index 712b597..858edc5 100644 --- a/suixinkan/App/AppServiceEnvironment.swift +++ b/suixinkan/App/AppServiceEnvironment.swift @@ -123,6 +123,10 @@ private struct TaskAPIEnvironmentKey: EnvironmentKey { static var defaultValue: TaskAPI { TaskAPI(client: EnvironmentServiceDefaults.apiClient()) } } +private struct TravelAlbumAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: TravelAlbumAPI { TravelAlbumAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + private struct WalletAPIEnvironmentKey: EnvironmentKey { static var defaultValue: WalletAPI { WalletAPI(client: EnvironmentServiceDefaults.apiClient()) } } @@ -267,6 +271,11 @@ extension EnvironmentValues { set { self[TaskAPIEnvironmentKey.self] = newValue } } + var travelAlbumAPI: TravelAlbumAPI { + get { self[TravelAlbumAPIEnvironmentKey.self] } + set { self[TravelAlbumAPIEnvironmentKey.self] = newValue } + } + var walletAPI: WalletAPI { get { self[WalletAPIEnvironmentKey.self] } set { self[WalletAPIEnvironmentKey.self] = newValue } diff --git a/suixinkan/Core/CameraTethering/CameraTethering.md b/suixinkan/Core/CameraTethering/CameraTethering.md new file mode 100644 index 0000000..69c4bd2 --- /dev/null +++ b/suixinkan/Core/CameraTethering/CameraTethering.md @@ -0,0 +1,27 @@ +# CameraTethering + +## 职责 + +提供 USB OTG 相机有线连接与 Sony PTP 边拍边传能力,不包含业务 API 或 OSS 上传。 + +## 核心组件 + +| 组件 | 说明 | +| --- | --- | +| `CameraServiceProtocol` | 相机服务抽象,上层只依赖此协议 | +| `SonyCameraService` | Sony 相机实现,封装 PTP 下载与连接状态 | +| `ImageCaptureDeviceManager` | ImageCaptureCore 设备浏览、会话与 PTP 事件 | +| `SonyPTPCommands` | Sony SDIO/PTP 命令封装 | +| `CameraDownloadStorage` | 本地下载目录与文件名消毒 | + +## 业务流程 + +1. `SonyCameraService.connect()` 启动 USB 设备搜索。 +2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。 +3. 拍摄事件触发 PTP 下载,文件写入 `Documents/CameraDownloads/`。 +4. 通过 `onNewAsset` 回调通知上层有新照片。 + +## 依赖 + +- ImageCaptureCore(系统框架) +- 无第三方库依赖 diff --git a/suixinkan/Core/CameraTethering/Factory/CameraServiceFactory.swift b/suixinkan/Core/CameraTethering/Factory/CameraServiceFactory.swift new file mode 100644 index 0000000..28300d7 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Factory/CameraServiceFactory.swift @@ -0,0 +1,12 @@ +import Foundation + +enum CameraServiceFactory { + static func make(brand: CameraBrand) -> any CameraServiceProtocol { + switch brand { + case .sony: + SonyCameraService() + case .canon, .nikon: + preconditionFailure("\(brand.displayName) 相机尚未实现") + } + } +} diff --git a/suixinkan/Core/CameraTethering/Models/CameraAsset.swift b/suixinkan/Core/CameraTethering/Models/CameraAsset.swift new file mode 100644 index 0000000..46e0447 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Models/CameraAsset.swift @@ -0,0 +1,23 @@ +import Foundation + +struct CameraAsset: Identifiable, Equatable, Hashable { + let id: String + let filename: String + let fileSize: Int64 + let creationDate: Date? + let isMovie: Bool + + init( + id: String, + filename: String, + fileSize: Int64, + creationDate: Date? = nil, + isMovie: Bool = false + ) { + self.id = id + self.filename = filename + self.fileSize = fileSize + self.creationDate = creationDate + self.isMovie = isMovie + } +} diff --git a/suixinkan/Core/CameraTethering/Models/CameraBrand.swift b/suixinkan/Core/CameraTethering/Models/CameraBrand.swift new file mode 100644 index 0000000..cb24f7a --- /dev/null +++ b/suixinkan/Core/CameraTethering/Models/CameraBrand.swift @@ -0,0 +1,17 @@ +import Foundation + +enum CameraBrand: String, CaseIterable, Identifiable { + case sony + case canon + case nikon + + var id: String { rawValue } + + var displayName: String { + switch self { + case .sony: "Sony" + case .canon: "Canon" + case .nikon: "Nikon" + } + } +} diff --git a/suixinkan/Core/CameraTethering/Models/CameraConnectionState.swift b/suixinkan/Core/CameraTethering/Models/CameraConnectionState.swift new file mode 100644 index 0000000..6215be8 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Models/CameraConnectionState.swift @@ -0,0 +1,29 @@ +import Foundation + +enum CameraConnectionState: Equatable { + case disconnected + case searching + case connecting + case connected(deviceName: String) + case error(String) + + var displayText: String { + switch self { + case .disconnected: + "未连接" + case .searching: + "正在搜索相机…" + case .connecting: + "正在连接…" + case .connected(let name): + "已连接:\(name)" + case .error(let message): + "错误:\(message)" + } + } + + var isConnected: Bool { + if case .connected = self { return true } + return false + } +} diff --git a/suixinkan/Core/CameraTethering/Protocol/CameraServiceProtocol.swift b/suixinkan/Core/CameraTethering/Protocol/CameraServiceProtocol.swift new file mode 100644 index 0000000..869a838 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Protocol/CameraServiceProtocol.swift @@ -0,0 +1,33 @@ +import Foundation + +enum CameraServiceError: LocalizedError { + case notConnected + case assetNotFound + case downloadFailed(String) + case permissionDenied + + var errorDescription: String? { + switch self { + case .notConnected: + "相机未连接" + case .assetNotFound: + "找不到相机内的文件" + case .downloadFailed(let reason): + "下载失败:\(reason)" + case .permissionDenied: + "未获得相机访问权限" + } + } +} + +protocol CameraServiceProtocol: AnyObject { + var brand: CameraBrand { get } + var connectionState: CameraConnectionState { get } + var onConnectionStateChange: ((CameraConnectionState) -> Void)? { get set } + var onNewAsset: ((CameraAsset) -> Void)? { get set } + + func connect() async + func disconnect() async + func listAssets() async throws -> [CameraAsset] + func downloadAsset(_ asset: CameraAsset) async throws -> URL +} diff --git a/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift b/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift new file mode 100644 index 0000000..fc3ccc4 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift @@ -0,0 +1,806 @@ +import CoreGraphics +import Foundation +import ImageCaptureCore + +final class CameraDownloadDelegate: NSObject, ICCameraDeviceDownloadDelegate { + var onComplete: ((ICCameraFile, URL?, Error?) -> Void)? + var onProgress: ((String, Double) -> Void)? + + @objc func didDownloadFile(_ file: ICCameraFile, error: Error?, options: [String: Any], contextInfo: UnsafeMutableRawPointer?) { + if let error { + CameraTetheringLogger.log("下载失败 \(file.name ?? "?"): \(error.localizedDescription)", logger: CameraTetheringLogger.transfer) + onComplete?(file, nil, error) + return + } + + let downloadOptions = options as? [ICDownloadOption: Any] ?? [:] + if let directory = downloadOptions[.downloadsDirectoryURL] as? URL, + let filename = downloadOptions[.savedFilename] as? String { + let url = directory.appendingPathComponent(filename) + CameraTetheringLogger.log("下载完成 \(filename) -> \(url.path)", logger: CameraTetheringLogger.transfer) + onComplete?(file, url, nil) + } else if let urlString = file.value(forKey: "url") as? String { + let url = URL(fileURLWithPath: urlString) + CameraTetheringLogger.log("下载完成 \(file.name ?? "?") -> \(url.path)", logger: CameraTetheringLogger.transfer) + onComplete?(file, url, nil) + } else { + CameraTetheringLogger.log("下载完成但无法定位路径: \(file.name ?? "?")", logger: CameraTetheringLogger.transfer) + onComplete?(file, nil, CameraServiceError.downloadFailed("无法定位下载文件路径")) + } + } + + @objc func didReceiveDownloadProgress(for file: ICCameraFile, downloadedBytes: off_t, maxBytes: off_t) { + guard maxBytes > 0, let name = file.name else { return } + onProgress?(name, Double(downloadedBytes) / Double(maxBytes)) + } +} + +final class ImageCaptureDeviceManager: NSObject { + var onConnectionStateChange: ((CameraConnectionState) -> Void)? + var onNewItems: (([ICCameraItem]) -> Void)? + var onBaselineCatalogReady: (() -> Void)? + var onPollTick: (() -> Void)? + var onPTPAssetReady: ((CameraAsset, URL) -> Void)? + var onError: ((String) -> Void)? + + private(set) var cameraDevice: ICCameraDevice? + private(set) var isCatalogReady = false + private var deviceBrowser: ICDeviceBrowser? + private let downloadDelegate = CameraDownloadDelegate() + private var transactionID: UInt32 = 1 + private var pendingDownloads: [String: CheckedContinuation] = [:] + private var ptpAssetURLs: [String: URL] = [:] + private var pollTimer: Timer? + private var baselineNotified = false + private var isRemoteSessionReady = false + private var isDownloadingPTPObject = false + private var propertyCheckTask: Task? + private var shotBufferPollTimer: Timer? + private var lastDownloadedObjectSignature: String? + private var lastShootingFileInfoValue: Int64? + private var shotDownloadQueue: [(handle: UInt32, reason: String)] = [] + private var isDrainingShotQueue = false + private var isEvaluatingShotBuffer = false + private var propertyCheckCoalesceScheduled = false + + var isBrowsing: Bool { deviceBrowser != nil } + + func startBrowsing() { + guard deviceBrowser == nil else { + CameraTetheringLogger.log("已在监听 USB 相机") + if cameraDevice == nil { + updateState(.searching) + } + return + } + + CameraTetheringLogger.log("开始搜索 USB 相机…") + updateState(.searching) + + let browser = ICDeviceBrowser() + browser.delegate = self + if #available(iOS 15.2, *) { + let mask = ICDeviceTypeMask( + rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue + ) ?? .camera + browser.browsedDeviceTypeMask = mask + CameraTetheringLogger.log("已设置 browsedDeviceTypeMask = Camera | Local USB") + } + deviceBrowser = browser + browser.start() + } + + func stopBrowsing() { + CameraTetheringLogger.log("停止搜索并断开") + propertyCheckTask?.cancel() + shotDownloadQueue.removeAll() + isDrainingShotQueue = false + isEvaluatingShotBuffer = false + propertyCheckCoalesceScheduled = false + propertyCheckTask = nil + stopShotBufferPolling() + stopPolling() + disconnect() + deviceBrowser?.stop() + deviceBrowser = nil + isCatalogReady = false + baselineNotified = false + updateState(.disconnected) + } + + func disconnect() { + propertyCheckTask?.cancel() + shotDownloadQueue.removeAll() + isDrainingShotQueue = false + isEvaluatingShotBuffer = false + propertyCheckCoalesceScheduled = false + propertyCheckTask = nil + stopShotBufferPolling() + stopPolling() + if let camera = cameraDevice { + camera.ptpEventHandler = { _ in } + camera.requestCloseSession() + camera.delegate = nil + } + cameraDevice = nil + transactionID = 1 + isCatalogReady = false + baselineNotified = false + isRemoteSessionReady = false + ptpAssetURLs.removeAll() + lastDownloadedObjectSignature = nil + lastShootingFileInfoValue = nil + shotDownloadQueue.removeAll() + isDrainingShotQueue = false + isEvaluatingShotBuffer = false + propertyCheckCoalesceScheduled = false + } + + func localURL(forAssetID assetID: String) -> URL? { + ptpAssetURLs[assetID] + } + + func allFiles() -> [ICCameraFile] { + guard let camera = cameraDevice else { return [] } + + if let mediaFiles = camera.mediaFiles, !mediaFiles.isEmpty { + return mediaFiles.compactMap { $0 as? ICCameraFile } + } + return collectFiles(from: camera.contents ?? []) + } + + func file(forAssetID assetID: String) -> ICCameraFile? { + allFiles().first { assetIdentifier(for: $0) == assetID } + } + + func download(file: ICCameraFile, to directory: URL) async throws -> URL { + guard let camera = cameraDevice else { + throw CameraServiceError.notConnected + } + + let filename = file.name ?? "photo_\(UUID().uuidString).jpg" + let assetID = assetIdentifier(for: file) + + CameraTetheringLogger.log("开始下载 \(filename), handle=\(file.ptpObjectHandle)", logger: CameraTetheringLogger.transfer) + + return try await withCheckedThrowingContinuation { continuation in + pendingDownloads[assetID] = continuation + + downloadDelegate.onComplete = { [weak self] completedFile, url, error in + guard let self else { return } + let completedID = self.assetIdentifier(for: completedFile) + guard let pending = self.pendingDownloads.removeValue(forKey: completedID) else { return } + + if let error { + pending.resume(throwing: error) + } else if let url { + pending.resume(returning: url) + } else { + pending.resume(throwing: CameraServiceError.downloadFailed("未知错误")) + } + } + + let options: [ICDownloadOption: Any] = [ + .downloadsDirectoryURL: directory, + .savedFilename: filename, + .overwrite: true + ] + + camera.requestDownloadFile( + file, + options: options, + downloadDelegate: downloadDelegate, + didDownloadSelector: #selector(CameraDownloadDelegate.didDownloadFile(_:error:options:contextInfo:)), + contextInfo: nil + ) + } + } + + func assetIdentifier(for file: ICCameraFile) -> String { + if file.ptpObjectHandle != 0 { + return "ptp_\(file.ptpObjectHandle)" + } + if let persistentID = file.value(forKey: "persistentID") as? String, !persistentID.isEmpty { + return persistentID + } + let name = file.name ?? "unknown" + let size = file.fileSize + let date = file.creationDate?.timeIntervalSince1970 ?? 0 + return "\(name)_\(size)_\(Int(date))" + } + + func makeAsset(from file: ICCameraFile) -> CameraAsset { + let filename = file.name ?? "unknown" + let isMovie = file.duration > 0 || Self.movieExtensions.contains(where: { filename.lowercased().hasSuffix($0) }) + return CameraAsset( + id: assetIdentifier(for: file), + filename: filename, + fileSize: file.fileSize, + creationDate: file.creationDate, + isMovie: isMovie + ) + } + + private static let movieExtensions = [".mp4", ".mov", ".m4v", ".avi"] + + private func collectFiles(from items: [ICCameraItem]) -> [ICCameraFile] { + var files: [ICCameraFile] = [] + for item in items { + if let file = item as? ICCameraFile { + files.append(file) + } else if let folder = item as? ICCameraFolder { + files.append(contentsOf: collectFiles(from: folder.contents ?? [])) + } + } + return files + } + + private func updateState(_ state: CameraConnectionState) { + CameraTetheringLogger.log("连接状态: \(state.displayText)") + onConnectionStateChange?(state) + } + + private func requestAuthorizations(completion: @escaping (Bool, Bool) -> Void) { + guard let browser = deviceBrowser else { + completion(false, false) + return + } + + var contentsGranted = false + var controlGranted = false + let group = DispatchGroup() + + group.enter() + browser.requestContentsAuthorization { status in + contentsGranted = status == .authorized + CameraTetheringLogger.log("内容授权: \(String(describing: status))") + group.leave() + } + + group.enter() + browser.requestControlAuthorization { status in + controlGranted = status == .authorized + CameraTetheringLogger.log("控制授权: \(String(describing: status))") + group.leave() + } + + group.notify(queue: .main) { + completion(contentsGranted, controlGranted) + } + } + + private func openSession(for camera: ICCameraDevice) { + updateState(.connecting) + camera.delegate = self + cameraDevice = camera + CameraTetheringLogger.log("打开相机会话: \(camera.name ?? "Unknown")") + camera.requestOpenSession() + } + + private func configureCameraAfterSession(_ camera: ICCameraDevice) { + let capabilities = camera.capabilities.joined(separator: ", ") + CameraTetheringLogger.log("相机能力: [\(capabilities)]") + CameraTetheringLogger.log("tetheredCaptureEnabled=\(camera.tetheredCaptureEnabled)") + + camera.ptpEventHandler = { [weak self] eventData in + self?.handlePTPEvent(eventData) + } + + Task { + let updatedID = await SonyPTPHelper.initializeRemoteSession( + on: camera, + startingTransactionID: transactionID + ) + transactionID = updatedID + isRemoteSessionReady = true + startShotBufferPolling() + schedulePropertyBasedShotCheck(reason: "PostHandshake", delay: 0.2) + } + } + + private func handlePTPEvent(_ eventData: Data) { + let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ") + CameraTetheringLogger.log("收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp) + + guard let container = try? SonyPTPHelper.parseContainer(eventData) else { + CameraTetheringLogger.log("PTP 事件解析失败", logger: CameraTetheringLogger.ptp) + return + } + + CameraTetheringLogger.log( + "PTP 事件码: 0x\(String(format: "%04X", container.code)), params=\(container.params)", + logger: CameraTetheringLogger.ptp + ) + + switch container.code { + case SonyPTPCommand.objectAdded, SonyPTPCommand.sdieObjectAdded: + guard isRemoteSessionReady else { break } + let handle = container.params.first ?? SonyPTPCommand.shotObjectHandle + enqueueShotDownload(handle: handle, reason: "ObjectAdded") + case SonyPTPCommand.sdieCapturedEvent: + guard isRemoteSessionReady else { break } + enqueueShotDownload(handle: SonyPTPCommand.shotObjectHandle, reason: "CapturedEvent") + case SonyPTPCommand.sdieObjectRemoved: + lastShootingFileInfoValue = nil + case SonyPTPCommand.sdieDevicePropChanged: + guard isRemoteSessionReady else { break } + schedulePropertyBasedShotCheck(reason: "DevicePropChanged") + default: + break + } + } + + private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval = 0.12) { + if isEvaluatingShotBuffer { + propertyCheckCoalesceScheduled = true + return + } + propertyCheckTask?.cancel() + propertyCheckTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { return } + await self?.runPropertyBasedShotCheck(reason: reason) + } + } + + private func runPropertyBasedShotCheck(reason: String) async { + if isEvaluatingShotBuffer { + propertyCheckCoalesceScheduled = true + return + } + isEvaluatingShotBuffer = true + defer { + isEvaluatingShotBuffer = false + if propertyCheckCoalesceScheduled { + propertyCheckCoalesceScheduled = false + Task { [weak self] in + try? await Task.sleep(nanoseconds: 80_000_000) + await self?.runPropertyBasedShotCheck(reason: "Coalesced") + } + } + } + await evaluateShotBufferFromProperties(reason: reason) + } + + private func evaluateShotBufferFromProperties(reason: String) async { + guard isRemoteSessionReady, let camera = cameraDevice else { return } + + var localTransactionID = transactionID + var properties = await SonyPTPHelper.fetchExtDeviceProperties( + on: camera, + transactionID: &localTransactionID, + onlyDiff: true + ) + transactionID = localTransactionID + + var shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo] + + // diff 响应常不含 D215(仅其它属性变化),需 fallback 全量读取或直接 Probe + if shootingFileInfo == nil, shouldFallbackFullPropertyFetch(for: reason) { + properties = await SonyPTPHelper.fetchExtDeviceProperties( + on: camera, + transactionID: &localTransactionID, + onlyDiff: false + ) + transactionID = localTransactionID + shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo] + CameraTetheringLogger.log("\(reason): diff 无 D215,已读取全量属性", logger: CameraTetheringLogger.ptp) + } + + if let shootingFileInfo { + CameraTetheringLogger.log( + "\(reason): D215(Shooting_File_Info)=0x\(String(format: "%04X", shootingFileInfo))", + logger: CameraTetheringLogger.ptp + ) + lastShootingFileInfoValue = shootingFileInfo + } else { + CameraTetheringLogger.log("\(reason): 属性包无 D215,改用 Probe 检测缓冲", logger: CameraTetheringLogger.ptp) + } + + if SonyPTPHelper.isShotBufferReady(shootingFileInfo) { + _ = await attemptFetchPendingShot(reason: "\(reason)-D215") + return + } + + if shouldProbeWithoutD215Ready(for: reason) { + _ = await attemptFetchPendingShot(reason: "\(reason)-Probe") + } + } + + private func shouldFallbackFullPropertyFetch(for reason: String) -> Bool { + reason.contains("DevicePropChanged") + || reason.hasPrefix("D215Poll") + || reason.hasPrefix("Coalesced") + } + + private func shouldProbeWithoutD215Ready(for reason: String) -> Bool { + reason.contains("DevicePropChanged") + || reason.hasPrefix("D215Poll") + || reason.hasPrefix("Coalesced") + || reason.hasPrefix("BufferDrain") + } + + /// Probe 缓冲区内对象;若 filename+size 与上次不同则入队下载。 + @discardableResult + private func attemptFetchPendingShot(reason: String) async -> Bool { + guard isRemoteSessionReady, let camera = cameraDevice else { return false } + + var localTransactionID = transactionID + guard let probe = await SonyPTPHelper.probeShotObject( + on: camera, + handle: SonyPTPCommand.shotObjectHandle, + transactionID: &localTransactionID + ) else { + return false + } + transactionID = localTransactionID + + let signature = SonyPTPHelper.shotObjectSignature(filename: probe.filename, size: probe.size) + if signature == lastDownloadedObjectSignature { + CameraTetheringLogger.log("\(reason): 缓冲无新片 signature=\(signature)", logger: CameraTetheringLogger.ptp) + return false + } + + enqueueShotDownload(handle: SonyPTPCommand.shotObjectHandle, reason: reason) + return true + } + + private func startShotBufferPolling() { + stopShotBufferPolling() + CameraTetheringLogger.log("启动 D215 缓冲轮询 (连拍时 0.25s / 空闲 1.0s)", logger: CameraTetheringLogger.ptp) + refreshShotBufferPollInterval() + } + + private func refreshShotBufferPollInterval() { + guard isRemoteSessionReady else { return } + let interval = (isDrainingShotQueue || !shotDownloadQueue.isEmpty) ? 0.25 : 1.0 + stopShotBufferPolling() + shotBufferPollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + guard let self, self.isRemoteSessionReady else { return } + Task { await self.evaluateShotBufferFromProperties(reason: "D215Poll") } + } + } + + private func stopShotBufferPolling() { + shotBufferPollTimer?.invalidate() + shotBufferPollTimer = nil + } + + /// 连拍时多个 PTP 事件会连续到达;入队串行处理,避免「下载进行中直接丢弃」。 + private func enqueueShotDownload(handle: UInt32, reason: String) { + shotDownloadQueue.append((handle: handle, reason: reason)) + CameraTetheringLogger.log( + "\(reason): 入队 PTP 下载 (队列=\(shotDownloadQueue.count))", + logger: CameraTetheringLogger.ptp + ) + refreshShotBufferPollInterval() + startShotDownloadDrainIfNeeded() + } + + private func startShotDownloadDrainIfNeeded() { + guard !isDrainingShotQueue else { return } + guard !shotDownloadQueue.isEmpty else { return } + + isDrainingShotQueue = true + Task { [weak self] in + await self?.drainShotDownloadQueue() + } + } + + private func drainShotDownloadQueue() async { + defer { + isDrainingShotQueue = false + refreshShotBufferPollInterval() + if !shotDownloadQueue.isEmpty { + startShotDownloadDrainIfNeeded() + } + } + + while !shotDownloadQueue.isEmpty { + let item = shotDownloadQueue.removeFirst() + _ = await downloadPTPObject( + handle: item.handle, + reason: item.reason, + maxAttempts: 8, + tryDirectGetObject: true + ) + try? await Task.sleep(nanoseconds: 120_000_000) + } + + await drainRemainingBuffer(maxExtraDownloads: 12) + } + + /// 连拍时部分事件可能在队列排空期间才写入缓冲;按 D215/Probe 继续拉取直到无新片。 + private func drainRemainingBuffer(maxExtraDownloads: Int) async { + for round in 1...maxExtraDownloads { + guard isRemoteSessionReady, let camera = cameraDevice else { break } + + var localTransactionID = transactionID + guard let probe = await SonyPTPHelper.probeShotObject( + on: camera, + handle: SonyPTPCommand.shotObjectHandle, + transactionID: &localTransactionID + ) else { + break + } + transactionID = localTransactionID + + let signature = SonyPTPHelper.shotObjectSignature(filename: probe.filename, size: probe.size) + if signature == lastDownloadedObjectSignature { + break + } + + let downloaded = await downloadPTPObject( + handle: SonyPTPCommand.shotObjectHandle, + reason: "BufferDrain-\(round)", + maxAttempts: 6, + tryDirectGetObject: true + ) + guard downloaded else { break } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + @discardableResult + private func downloadPTPObject( + handle: UInt32, + reason: String, + maxAttempts: Int = 3, + tryDirectGetObject: Bool = false + ) async -> Bool { + guard isRemoteSessionReady, let camera = cameraDevice else { + CameraTetheringLogger.log("\(reason): Sony 远程会话尚未就绪", logger: CameraTetheringLogger.ptp) + return false + } + guard !isDownloadingPTPObject else { + CameraTetheringLogger.log("\(reason): 下载重入,重新入队", logger: CameraTetheringLogger.ptp) + shotDownloadQueue.insert((handle: handle, reason: reason), at: 0) + return false + } + + isDownloadingPTPObject = true + defer { isDownloadingPTPObject = false } + + var localTransactionID = transactionID + + for attempt in 1...maxAttempts { + if attempt > 1 { + try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000) + } + + var probeFilename = "DSC_\(Int(Date().timeIntervalSince1970)).JPG" + var probeSize: UInt32 = 0 + + if let probe = await SonyPTPHelper.probeShotObject( + on: camera, + handle: handle, + transactionID: &localTransactionID + ) { + probeFilename = probe.filename + probeSize = probe.size + } else if !tryDirectGetObject { + if attempt == maxAttempts { + CameraTetheringLogger.log("\(reason): 相机缓冲区内暂无可用照片", logger: CameraTetheringLogger.ptp) + } + continue + } + + let signature: String + if probeSize > 0 { + signature = SonyPTPHelper.shotObjectSignature(filename: probeFilename, size: probeSize) + if signature == lastDownloadedObjectSignature { + CameraTetheringLogger.log("\(reason): 照片已下载过 \(probeFilename),跳过", logger: CameraTetheringLogger.ptp) + return false + } + } else { + signature = "\(probeFilename)_attempt\(attempt)_\(Int(Date().timeIntervalSince1970))" + } + + guard let result = await SonyPTPHelper.downloadObject( + handle: handle, + on: camera, + transactionID: &localTransactionID, + skipObjectInfo: tryDirectGetObject && probeSize == 0 + ) else { + if attempt == maxAttempts { + CameraTetheringLogger.log("\(reason): PTP 下载失败 handle=0x\(String(format: "%08X", handle))", logger: CameraTetheringLogger.ptp) + } + continue + } + + transactionID = localTransactionID + lastDownloadedObjectSignature = probeSize > 0 ? signature : "\(result.filename)_\(result.data.count)" + + let assetID = "ptp_\(handle)_\(result.filename)_\(result.data.count)" + let destination = CameraDownloadStorage.uniqueLocalURL(for: result.filename) + do { + try result.data.write(to: destination, options: .atomic) + } catch { + CameraTetheringLogger.log("PTP 文件写入失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer) + return false + } + + let asset = CameraAsset( + id: assetID, + filename: result.filename, + fileSize: Int64(result.data.count), + creationDate: Date(), + isMovie: false + ) + ptpAssetURLs[assetID] = destination + + CameraTetheringLogger.log( + "[\(reason)] PTP 下载完成: \(result.filename) -> \(destination.path)", + logger: CameraTetheringLogger.transfer + ) + onPTPAssetReady?(asset, destination) + return true + } + + return false + } + + private func notifyBaselineOrPoll(reason: String) { + guard isCatalogReady else { + CameraTetheringLogger.log("\(reason): 目录尚未就绪,等待 catalog ready") + return + } + onPollTick?() + } + + private func notifyBaselineIfNeeded() { + guard !baselineNotified else { return } + baselineNotified = true + isCatalogReady = true + + let fileCount = allFiles().count + CameraTetheringLogger.log("目录就绪,当前 \(fileCount) 个文件,建立基线") + onBaselineCatalogReady?() + startPolling() + } + + private func startPolling() { + stopPolling() + CameraTetheringLogger.log("启动轮询检测新照片 (每 1.5s)") + + pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in + guard let self, self.isCatalogReady else { return } + self.onPollTick?() + } + } + + private func stopPolling() { + pollTimer?.invalidate() + pollTimer = nil + } +} + +extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate { + func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) { + CameraTetheringLogger.log("发现设备: \(device.name ?? "?"), type=\(device.type), transport=\(device.transportType ?? "?")") + + guard let camera = device as? ICCameraDevice else { + CameraTetheringLogger.log("非相机设备,忽略") + return + } + guard cameraDevice == nil else { + CameraTetheringLogger.log("已有连接中的相机,忽略") + return + } + + requestAuthorizations { [weak self] contentsGranted, controlGranted in + guard let self else { return } + guard contentsGranted else { + let message = "未获得相机内容访问权限,请在系统设置中允许" + CameraTetheringLogger.log(message) + self.onError?(message) + self.updateState(.error("权限被拒绝")) + return + } + if !controlGranted { + CameraTetheringLogger.log("警告: 控制授权未通过,仍尝试连接(下载通常只需内容授权)") + } + self.openSession(for: camera) + } + } + + func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) { + CameraTetheringLogger.log("设备移除: \(device.name ?? "?")") + if device.uuidString == cameraDevice?.uuidString { + disconnect() + updateState(.disconnected) + } + } +} + +extension ImageCaptureDeviceManager: ICDeviceDelegate { + func didRemove(_ device: ICDevice) { + CameraTetheringLogger.log("didRemove: \(device.name ?? "?")") + if device.uuidString == cameraDevice?.uuidString { + disconnect() + updateState(.disconnected) + } + } + + func device(_ device: ICDevice, didOpenSessionWithError error: Error?) { + if let error { + CameraTetheringLogger.log("会话打开失败: \(error.localizedDescription)") + onError?(error.localizedDescription) + updateState(.error(error.localizedDescription)) + return + } + + guard let camera = device as? ICCameraDevice else { return } + + let name = camera.name ?? "Camera" + CameraTetheringLogger.log("会话已打开: \(name)") + updateState(.connected(deviceName: name)) + configureCameraAfterSession(camera) + } + + func device(_ device: ICDevice, didCloseSessionWithError error: Error?) { + if let error { + CameraTetheringLogger.log("会话关闭: \(error.localizedDescription)") + } + if device.uuidString == cameraDevice?.uuidString { + cameraDevice = nil + isCatalogReady = false + baselineNotified = false + stopPolling() + updateState(.disconnected) + } + } +} + +extension ImageCaptureDeviceManager: ICCameraDeviceDelegate { + @objc(cameraDevice:didAddItems:) + func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) { + let names = items.compactMap { ($0 as? ICCameraFile)?.name ?? $0.name } + CameraTetheringLogger.log("didAddItems: \(names.joined(separator: ", "))") + + for item in items { + if let file = item as? ICCameraFile { + CameraTetheringLogger.log(" + \(file.name ?? "?") addedAfterCatalog=\(file.wasAddedAfterContentCatalogCompleted)") + } + } + + onNewItems?(items) + } + + @objc(cameraDevice:didRemoveItems:) + func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) { + let names = items.compactMap { $0.name } + CameraTetheringLogger.log("didRemoveItems: \(names.joined(separator: ", "))") + } + + @objc(cameraDevice:didRenameItems:) + func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) { + } + + func cameraDevice(_ camera: ICCameraDevice, didReceiveThumbnail thumbnail: CGImage?, for item: ICCameraItem, error: Error?) { + } + + func cameraDevice(_ camera: ICCameraDevice, didReceiveMetadata metadata: [AnyHashable: Any]?, for item: ICCameraItem, error: Error?) { + } + + func deviceDidBecomeReady(withCompleteContentCatalog device: ICCameraDevice) { + CameraTetheringLogger.log("deviceDidBecomeReadyWithCompleteContentCatalog, 文件数=\(allFiles().count)") + notifyBaselineIfNeeded() + } + + func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) { + CameraTetheringLogger.log("cameraDeviceDidChangeCapability") + } + + func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) { + CameraTetheringLogger.log("cameraDeviceDidRemoveAccessRestriction") + } + + func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) { + CameraTetheringLogger.log("cameraDeviceDidEnableAccessRestriction") + } + + func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent eventData: Data) { + handlePTPEvent(eventData) + } +} diff --git a/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift b/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift new file mode 100644 index 0000000..a2642d3 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift @@ -0,0 +1,162 @@ +import Foundation +import ImageCaptureCore + +@MainActor +final class SonyCameraService: CameraServiceProtocol { + let brand: CameraBrand = .sony + + private(set) var connectionState: CameraConnectionState = .disconnected + var onConnectionStateChange: ((CameraConnectionState) -> Void)? + var onNewAsset: ((CameraAsset) -> Void)? + + private let deviceManager = ImageCaptureDeviceManager() + private var knownAssetIDs: Set = [] + private var baselineEstablished = false + + init() { + deviceManager.onConnectionStateChange = { [weak self] state in + Task { @MainActor in + self?.connectionState = state + self?.onConnectionStateChange?(state) + } + } + + deviceManager.onNewItems = { [weak self] items in + Task { @MainActor in + self?.handleNewItems(items, source: "didAddItems") + } + } + + deviceManager.onBaselineCatalogReady = { [weak self] in + Task { @MainActor in + self?.establishBaseline() + } + } + + deviceManager.onPollTick = { [weak self] in + Task { @MainActor in + self?.scanForNewAssets(source: "poll") + } + } + + deviceManager.onPTPAssetReady = { [weak self] asset, _ in + Task { @MainActor in + self?.handlePTPAsset(asset) + } + } + + deviceManager.onError = { [weak self] message in + Task { @MainActor in + CameraTetheringLogger.log("相机错误: \(message)") + self?.connectionState = .error(message) + self?.onConnectionStateChange?(.error(message)) + } + } + } + + func connect() async { + CameraTetheringLogger.log("SonyCameraService.connect()") + let shouldResetBaseline = !deviceManager.isBrowsing + deviceManager.startBrowsing() + if shouldResetBaseline { + knownAssetIDs.removeAll() + baselineEstablished = false + } + } + + func disconnect() async { + CameraTetheringLogger.log("SonyCameraService.disconnect()") + deviceManager.stopBrowsing() + knownAssetIDs.removeAll() + baselineEstablished = false + connectionState = .disconnected + onConnectionStateChange?(.disconnected) + } + + func listAssets() async throws -> [CameraAsset] { + guard connectionState.isConnected else { + throw CameraServiceError.notConnected + } + let assets = deviceManager.allFiles().map { deviceManager.makeAsset(from: $0) } + CameraTetheringLogger.log("listAssets: \(assets.count) 个文件") + return assets + } + + func downloadAsset(_ asset: CameraAsset) async throws -> URL { + guard connectionState.isConnected else { + throw CameraServiceError.notConnected + } + if let cachedURL = deviceManager.localURL(forAssetID: asset.id) { + CameraTetheringLogger.log("downloadAsset: 返回 PTP 缓存 \(asset.filename)", logger: CameraTetheringLogger.transfer) + return cachedURL + } + guard let file = deviceManager.file(forAssetID: asset.id) else { + CameraTetheringLogger.log("downloadAsset: 找不到文件 \(asset.filename) id=\(asset.id)", logger: CameraTetheringLogger.transfer) + throw CameraServiceError.assetNotFound + } + + let directory = CameraDownloadStorage.downloadsDirectory + return try await deviceManager.download(file: file, to: directory) + } + + private func handlePTPAsset(_ asset: CameraAsset) { + guard !knownAssetIDs.contains(asset.id) else { return } + knownAssetIDs.insert(asset.id) + CameraTetheringLogger.log( + "[PTP] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)", + logger: CameraTetheringLogger.transfer + ) + onNewAsset?(asset) + } + + /// 连接后首次目录就绪:已有照片记入基线;拍摄于枚举期间的新照片立即传输。 + private func establishBaseline() { + guard !baselineEstablished else { return } + baselineEstablished = true + + let files = deviceManager.allFiles() + var skipped = 0 + var newCount = 0 + + for file in files { + if file.wasAddedAfterContentCatalogCompleted { + emitAssetIfNew(file: file, source: "baseline-new") + newCount += 1 + } else { + knownAssetIDs.insert(deviceManager.assetIdentifier(for: file)) + skipped += 1 + } + } + CameraTetheringLogger.log("基线已建立: 跳过已有 \(skipped) 张, 立即传输新拍 \(newCount) 张") + } + + private func handleNewItems(_ items: [ICCameraItem], source: String) { + guard baselineEstablished else { + CameraTetheringLogger.log("\(source): 基线未建立,暂存处理") + return + } + + for item in items { + guard let file = item as? ICCameraFile else { continue } + emitAssetIfNew(file: file, source: source) + } + } + + private func scanForNewAssets(source: String) { + guard baselineEstablished else { return } + + let files = deviceManager.allFiles() + for file in files { + emitAssetIfNew(file: file, source: source) + } + } + + private func emitAssetIfNew(file: ICCameraFile, source: String) { + let asset = deviceManager.makeAsset(from: file) + guard !knownAssetIDs.contains(asset.id) else { return } + + knownAssetIDs.insert(asset.id) + CameraTetheringLogger.log("[\(source)] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)", logger: CameraTetheringLogger.transfer) + onNewAsset?(asset) + } +} diff --git a/suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift b/suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift new file mode 100644 index 0000000..65a6eb7 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift @@ -0,0 +1,571 @@ +import Foundation +import ImageCaptureCore + +enum PTPContainerType: UInt16 { + case command = 0x0001 + case data = 0x0002 + case response = 0x0003 + case event = 0x0004 +} + +struct PTPContainer { + let length: UInt32 + let type: UInt16 + let code: UInt16 + let transactionID: UInt32 + let params: [UInt32] +} + +enum PTPParseError: Error { + case tooShort + case invalidLength +} + +enum SonyPTPCommand { + static let getDeviceInfo: UInt16 = 0x1001 + static let openSession: UInt16 = 0x1002 + static let closeSession: UInt16 = 0x1003 + static let getObjectHandles: UInt16 = 0x1007 + static let getObjectInfo: UInt16 = 0x1008 + static let getObject: UInt16 = 0x1009 + static let objectAdded: UInt16 = 0x4002 + static let sdieObjectAdded: UInt16 = 0xC201 + static let sdieObjectRemoved: UInt16 = 0xC202 + static let sdieDevicePropChanged: UInt16 = 0xC203 + static let sdieCapturedEvent: UInt16 = 0xC206 + static let sdioConnect: UInt16 = 0x9201 + static let sdioGetExtDeviceInfo: UInt16 = 0x9202 + static let sdioSetExtDevicePropValue: UInt16 = 0x9205 + static let sdioGetAllExtDevicePropInfo: UInt16 = 0x9209 + static let positionKeySetting: UInt16 = 0xD25A + static let stillImageSaveDestination: UInt16 = 0xD222 + static let stillImageTransSize: UInt16 = 0xD268 + static let shootingFileInfo: UInt16 = 0xD215 + static let shotObjectHandle: UInt32 = 0xFFFF_C001 + static let ptpResponseOk: UInt16 = 0x2001 + static let saveDestinationHostPC: UInt16 = 1 + static let saveDestinationHostAndCamera: UInt16 = 2 + static let shotBufferReadyThreshold: Int64 = 0x8000 +} + +private enum PTPDataType: UInt16 { + case int8 = 0x0001 + case uint8 = 0x0002 + case int16 = 0x0003 + case uint16 = 0x0004 + case int32 = 0x0005 + case uint32 = 0x0006 + case int64 = 0x0007 + case uint64 = 0x0008 + case string = 0xFFFF +} + +enum SonyPTPHelper { + static func buildCommand( + code: UInt16, + transactionID: UInt32, + parameters: [UInt32] = [] + ) -> Data { + let length = UInt32(12 + parameters.count * 4) + var data = Data() + appendUInt32LE(length, to: &data) + appendUInt16LE(PTPContainerType.command.rawValue, to: &data) + appendUInt16LE(code, to: &data) + appendUInt32LE(transactionID, to: &data) + for parameter in parameters { + appendUInt32LE(parameter, to: &data) + } + return data + } + + static func parseContainer(_ data: Data) throws -> PTPContainer { + guard data.count >= 12 else { throw PTPParseError.tooShort } + + let length = readUInt32LE(data, offset: 0) + guard length >= 12, data.count >= Int(length) else { throw PTPParseError.invalidLength } + + let type = readUInt16LE(data, offset: 4) + let code = readUInt16LE(data, offset: 6) + let transactionID = readUInt32LE(data, offset: 8) + + var params: [UInt32] = [] + var offset = 12 + while offset + 4 <= Int(length) { + params.append(readUInt32LE(data, offset: offset)) + offset += 4 + } + + return PTPContainer( + length: length, + type: type, + code: code, + transactionID: transactionID, + params: params + ) + } + + static func parseEventCode(from eventData: Data) -> UInt16? { + guard let container = try? parseContainer(eventData) else { return nil } + return container.code + } + + static func parseObjectCompressedSize(_ data: Data) -> UInt32? { + guard data.count >= 12 else { return nil } + return readUInt32LE(data, offset: 8) + } + + static func parseObjectInfoFilename(_ data: Data) -> String? { + guard data.count >= 53 else { return nil } + + if let filename = parsePTPString(data, offset: 52)?.string, !filename.isEmpty { + return CameraDownloadStorage.sanitizeFilename(filename) + } + + var offset = 52 + var codeUnits: [UInt16] = [] + while offset + 1 < data.count { + let unit = readUInt16LE(data, offset: offset) + if unit == 0 { break } + codeUnits.append(unit) + offset += 2 + } + + guard !codeUnits.isEmpty else { return nil } + let raw = String(utf16CodeUnits: codeUnits, count: codeUnits.count) + return CameraDownloadStorage.sanitizeFilename(raw) + } + + static func parsePTPString(_ data: Data, offset start: Int) -> (string: String, nextOffset: Int)? { + guard start < data.count else { return nil } + let charCount = Int(data[start]) + var offset = start + 1 + guard charCount > 0, offset + charCount * 2 <= data.count else { return nil } + + var codeUnits: [UInt16] = [] + codeUnits.reserveCapacity(charCount) + for index in 0.. UInt32 { + var transactionID = startingTransactionID + + CameraTetheringLogger.log("开始 Sony SDIO 握手", logger: CameraTetheringLogger.ptp) + + let step1 = await sendCommand( + on: camera, + code: SonyPTPCommand.sdioConnect, + transactionID: &transactionID, + parameters: [1, 0, 0], + outData: nil, + label: "SDIO Connect(1)" + ) + guard step1.success else { return transactionID } + + let step2 = await sendCommand( + on: camera, + code: SonyPTPCommand.sdioConnect, + transactionID: &transactionID, + parameters: [2, 0, 0], + outData: nil, + label: "SDIO Connect(2)" + ) + guard step2.success else { return transactionID } + + let step3 = await sendCommand( + on: camera, + code: SonyPTPCommand.sdioGetExtDeviceInfo, + transactionID: &transactionID, + parameters: [0x012C, 1], + outData: nil, + label: "SDIO GetExtDeviceInfo" + ) + guard step3.success else { return transactionID } + + let step4 = await sendCommand( + on: camera, + code: SonyPTPCommand.sdioConnect, + transactionID: &transactionID, + parameters: [3, 0, 0], + outData: nil, + label: "SDIO Connect(3)" + ) + guard step4.success else { return transactionID } + + _ = await sendCommand( + on: camera, + code: SonyPTPCommand.sdioGetAllExtDevicePropInfo, + transactionID: &transactionID, + parameters: [1], + outData: nil, + label: "SDIO GetAllExtDevicePropInfo" + ) + + await configureRemoteCapture(on: camera, transactionID: &transactionID) + CameraTetheringLogger.log("Sony SDIO 握手完成", logger: CameraTetheringLogger.ptp) + return transactionID + } + + static func probeShotObject( + on camera: ICCameraDevice, + handle: UInt32 = SonyPTPCommand.shotObjectHandle, + transactionID: inout UInt32 + ) async -> (filename: String, size: UInt32)? { + let infoResult = await sendCommand( + on: camera, + code: SonyPTPCommand.getObjectInfo, + transactionID: &transactionID, + parameters: [handle], + outData: nil, + label: "ProbeObjectInfo(0x\(String(format: "%08X", handle)))" + ) + guard infoResult.success else { return nil } + + guard let size = parseObjectCompressedSize(infoResult.inData), size > 0 else { + return nil + } + + let filename = parseObjectInfoFilename(infoResult.inData) + ?? CameraDownloadStorage.sanitizeFilename("DSC_\(Int(Date().timeIntervalSince1970)).JPG") + return (filename, size) + } + + static func downloadObject( + handle: UInt32, + on camera: ICCameraDevice, + transactionID: inout UInt32, + skipObjectInfo: Bool = false + ) async -> (filename: String, data: Data)? { + var filename = CameraDownloadStorage.sanitizeFilename("DSC_\(Int(Date().timeIntervalSince1970)).JPG") + + if !skipObjectInfo { + let infoResult = await sendCommand( + on: camera, + code: SonyPTPCommand.getObjectInfo, + transactionID: &transactionID, + parameters: [handle], + outData: nil, + label: "GetObjectInfo(0x\(String(format: "%08X", handle)))" + ) + if infoResult.success, let parsedName = parseObjectInfoFilename(infoResult.inData) { + filename = parsedName + } + } + + let objectResult = await sendCommand( + on: camera, + code: SonyPTPCommand.getObject, + transactionID: &transactionID, + parameters: [handle], + outData: nil, + label: "GetObject(0x\(String(format: "%08X", handle)))" + ) + guard objectResult.success, isLikelyImageData(objectResult.inData) else { return nil } + + return (filename, objectResult.inData) + } + + static func isLikelyImageData(_ data: Data) -> Bool { + guard data.count >= 3 else { return false } + if data[0] == 0xFF, data[1] == 0xD8, data[2] == 0xFF { return true } + if data.count >= 4, data[0] == 0x89, data[1] == 0x50, data[2] == 0x4E, data[3] == 0x47 { return true } + return data.count > 1024 + } + + static func isResponseOK(_ response: Data) -> Bool { + guard !response.isEmpty else { return true } + guard let parsed = try? parseContainer(response) else { return false } + return parsed.code == SonyPTPCommand.ptpResponseOk + } + + static func fetchExtDeviceProperties( + on camera: ICCameraDevice, + transactionID: inout UInt32, + onlyDiff: Bool = true + ) async -> [UInt16: Int64] { + let result = await sendCommand( + on: camera, + code: SonyPTPCommand.sdioGetAllExtDevicePropInfo, + transactionID: &transactionID, + parameters: [onlyDiff ? 1 : 0], + outData: nil, + label: "GetAllExtDevicePropInfo(\(onlyDiff ? "diff" : "all"))" + ) + guard result.success else { return [:] } + return parseExtDeviceProperties(result.inData) + } + + static func parseExtDeviceProperties(_ data: Data) -> [UInt16: Int64] { + guard data.count >= 8 else { return [:] } + + var properties: [UInt16: Int64] = [:] + var offset = 8 + + while offset + 6 <= data.count { + let propertyCode = readUInt16LE(data, offset: offset) + offset += 2 + let dataType = readUInt16LE(data, offset: offset) + offset += 2 + offset += 2 // getset, isenabled + + guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { break } + guard let currentValue = readVariableValue(dataType: dataType, data: data, offset: &offset) else { break } + properties[propertyCode] = currentValue + + guard offset < data.count else { break } + let formFlag = data[offset] + offset += 1 + guard skipFormPayload(formFlag: formFlag, dataType: dataType, data: data, offset: &offset) else { break } + } + + return properties + } + + static func isShotBufferReady(_ shootingFileInfo: Int64?) -> Bool { + guard let shootingFileInfo else { return false } + return shootingFileInfo > SonyPTPCommand.shotBufferReadyThreshold + } + + static func shotObjectSignature(filename: String, size: UInt32) -> String { + "\(filename)_\(size)" + } + + private static func readVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Int64? { + guard let type = PTPDataType(rawValue: dataType) else { return nil } + + switch type { + case .int8: + guard offset < data.count else { return nil } + let value = Int64(Int8(bitPattern: data[offset])) + offset += 1 + return value + case .uint8: + guard offset < data.count else { return nil } + let value = Int64(data[offset]) + offset += 1 + return value + case .int16: + guard offset + 1 < data.count else { return nil } + let value = Int64(Int16(bitPattern: readUInt16LE(data, offset: offset))) + offset += 2 + return value + case .uint16: + guard offset + 1 < data.count else { return nil } + let value = Int64(readUInt16LE(data, offset: offset)) + offset += 2 + return value + case .int32: + guard offset + 3 < data.count else { return nil } + let value = Int64(Int32(bitPattern: readUInt32LE(data, offset: offset))) + offset += 4 + return value + case .uint32: + guard offset + 3 < data.count else { return nil } + let value = Int64(readUInt32LE(data, offset: offset)) + offset += 4 + return value + case .int64, .uint64: + guard offset + 7 < data.count else { return nil } + offset += 8 + return 0 + case .string: + guard offset < data.count else { return nil } + let length = Int(data[offset]) + offset += 1 + length * 2 + return 0 + } + } + + private static func skipVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Bool { + readVariableValue(dataType: dataType, data: data, offset: &offset) != nil + } + + private static func skipFormPayload(formFlag: UInt8, dataType: UInt16, data: Data, offset: inout Int) -> Bool { + switch formFlag { + case 0: + return true + case 1: + for _ in 0..<3 { + guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false } + } + return true + case 2: + guard offset + 1 < data.count else { return false } + var enumCount = Int(readUInt16LE(data, offset: offset)) + offset += 2 + for _ in 0.. Bool { + var payload = Data() + appendUInt32LE(value, to: &payload) + + return await sendCommand( + on: camera, + code: SonyPTPCommand.sdioSetExtDevicePropValue, + transactionID: &transactionID, + parameters: [UInt32(propertyCode), 1], + outData: payload, + label: label + ).success + } + + private static func setDeviceProperty( + on camera: ICCameraDevice, + propertyCode: UInt16, + value: UInt16, + transactionID: inout UInt32, + label: String + ) async -> Bool { + var payload = Data() + appendUInt16LE(value, to: &payload) + + return await sendCommand( + on: camera, + code: SonyPTPCommand.sdioSetExtDevicePropValue, + transactionID: &transactionID, + parameters: [UInt32(propertyCode), 1], + outData: payload, + label: label + ).success + } + + private struct PTPCommandResult { + let success: Bool + let response: Data + let inData: Data + } + + private static func sendCommand( + on camera: ICCameraDevice, + code: UInt16, + transactionID: inout UInt32, + parameters: [UInt32], + outData: Data?, + label: String + ) async -> PTPCommandResult { + let currentID = transactionID + transactionID += 1 + + let commandData = buildCommand( + code: code, + transactionID: currentID, + parameters: parameters + ) + + return await withCheckedContinuation { continuation in + camera.requestSendPTPCommand( + commandData, + outData: outData, + completion: { inData, response, error in + if let error { + CameraTetheringLogger.log("PTP \(label) 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.ptp) + continuation.resume(returning: PTPCommandResult(success: false, response: response, inData: inData)) + return + } + + var commandSuccess = true + if !response.isEmpty { + do { + let parsed = try parseContainer(response) + if parsed.code != SonyPTPCommand.ptpResponseOk { + CameraTetheringLogger.log( + "PTP \(label) 响应码: 0x\(String(format: "%04X", parsed.code)) (非 OK)", + logger: CameraTetheringLogger.ptp + ) + commandSuccess = false + } + } catch { + CameraTetheringLogger.log("PTP \(label) 响应解析失败", logger: CameraTetheringLogger.ptp) + commandSuccess = false + } + } + + if commandSuccess { + CameraTetheringLogger.log( + "PTP \(label) 成功, inData=\(inData.count) bytes", + logger: CameraTetheringLogger.ptp + ) + } + continuation.resume(returning: PTPCommandResult(success: commandSuccess, response: response, inData: inData)) + } + ) + } + } + + private static func appendUInt16LE(_ value: UInt16, to data: inout Data) { + var littleEndian = value.littleEndian + withUnsafeBytes(of: &littleEndian) { raw in + data.append(contentsOf: raw) + } + } + + private static func appendUInt32LE(_ value: UInt32, to data: inout Data) { + var littleEndian = value.littleEndian + withUnsafeBytes(of: &littleEndian) { raw in + data.append(contentsOf: raw) + } + } + + static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 { + UInt16(data[offset]) + | (UInt16(data[offset + 1]) << 8) + } + + static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 { + UInt32(data[offset]) + | (UInt32(data[offset + 1]) << 8) + | (UInt32(data[offset + 2]) << 16) + | (UInt32(data[offset + 3]) << 24) + } +} diff --git a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift b/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift new file mode 100644 index 0000000..8e17b95 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift @@ -0,0 +1,47 @@ +// +// CameraDownloadStorage.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 相机下载文件本地存储工具,负责目录创建与安全文件名处理。 +enum CameraDownloadStorage { + /// 相机照片下载根目录。 + static var downloadsDirectory: URL { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let directory = documents.appendingPathComponent("CameraDownloads", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + /// 生成带时间戳的唯一本地文件 URL,避免文件名冲突。 + static func uniqueLocalURL(for filename: String) -> URL { + let timestamp = Int(Date().timeIntervalSince1970) + let safeName = sanitizeFilename(filename) + return downloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)") + } + + /// 将相机 PTP 文件名转为 iOS 文件系统可安全使用的 ASCII 名称。 + static func sanitizeFilename(_ filename: String) -> String { + let trimmed = filename + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") + + let ascii = trimmed.unicodeScalars.filter { scalar in + scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "." || scalar == "_" || scalar == "-") + } + + var name = String(String.UnicodeScalarView(ascii)) + if name.isEmpty { + name = "DSC_\(Int(Date().timeIntervalSince1970)).JPG" + } + if !name.contains(".") { + name += ".JPG" + } + return name + } +} diff --git a/suixinkan/Core/CameraTethering/Utilities/CameraTetheringLogger.swift b/suixinkan/Core/CameraTethering/Utilities/CameraTetheringLogger.swift new file mode 100644 index 0000000..f766b43 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Utilities/CameraTetheringLogger.swift @@ -0,0 +1,23 @@ +// +// CameraTetheringLogger.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation +import os + +/// 相机有线传输模块日志工具,按 Camera / Transfer / PTP 分类输出。 +enum CameraTetheringLogger { + private static let subsystem = Bundle.main.bundleIdentifier ?? "com.suixinkan" + + static let camera = Logger(subsystem: subsystem, category: "Camera") + static let transfer = Logger(subsystem: subsystem, category: "Transfer") + static let ptp = Logger(subsystem: subsystem, category: "PTP") + + /// 写入指定分类的 info 日志。 + static func log(_ message: String, logger: Logger = camera) { + logger.info("\(message, privacy: .public)") + } +} diff --git a/suixinkan/Core/CameraTransfer/CameraAssetUploadSink.swift b/suixinkan/Core/CameraTransfer/CameraAssetUploadSink.swift new file mode 100644 index 0000000..5760208 --- /dev/null +++ b/suixinkan/Core/CameraTransfer/CameraAssetUploadSink.swift @@ -0,0 +1,20 @@ +// +// CameraAssetUploadSink.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 相机文件下载完成后的上传登记入口,业务模块各自实现(如旅拍相册 OSS + 后端登记)。 +@MainActor +protocol CameraAssetUploadSink: AnyObject { + /// 上传本地文件并返回远端 URL。 + func upload( + localURL: URL, + fileName: String, + fileType: Int, + progress: @escaping @Sendable (Int) -> Void + ) async throws -> String +} diff --git a/suixinkan/Core/CameraTransfer/CameraTransfer.md b/suixinkan/Core/CameraTransfer/CameraTransfer.md new file mode 100644 index 0000000..7dd0d21 --- /dev/null +++ b/suixinkan/Core/CameraTransfer/CameraTransfer.md @@ -0,0 +1,25 @@ +# CameraTransfer + +## 职责 + +编排相机照片从下载到上传的通用流程,通过 `CameraAssetUploadSink` 与业务层解耦。 + +## 核心组件 + +| 组件 | 说明 | +| --- | --- | +| `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 | +| `CameraTransferTask` | 单条传输任务及状态 | +| `CameraAssetUploadSink` | 上传协议,由业务模块实现 | + +## 业务流程 + +1. 相机服务回调 `onNewAsset`。 +2. Pipeline 下载文件到本地并更新任务状态。 +3. 若开启自动上传,调用 `CameraAssetUploadSink.upload`。 +4. 上传成功后任务状态变为 `uploaded`。 + +## 与业务层关系 + +- 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 Sink:OSS 上传 + `upload-material` 登记。 +- Pipeline 不依赖任何 HTTP 或 OSS 具体实现。 diff --git a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift new file mode 100644 index 0000000..54da877 --- /dev/null +++ b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift @@ -0,0 +1,249 @@ +// +// CameraTransferPipeline.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 相机传输编排器,负责监听新照片、下载到本地并按需调用上传 Sink。 +@MainActor +final class CameraTransferPipeline { + var onTasksUpdated: (([CameraTransferTask]) -> Void)? + + private(set) var tasks: [CameraTransferTask] = [] + private let cameraService: any CameraServiceProtocol + private var uploadSink: (any CameraAssetUploadSink)? + private var uploadEnabled = true + private var isProcessing = false + private var inFlightAssetIDs: Set = [] + private let maxRetries = 3 + + /// 初始化传输管道,并注入相机服务。 + init(cameraService: any CameraServiceProtocol) { + self.cameraService = cameraService + cameraService.onNewAsset = { [weak self] asset in + Task { @MainActor in + await self?.handleNewAsset(asset, skipIfExists: false) + } + } + } + + /// 配置上传 Sink 与是否自动上传。 + func configure(uploadSink: (any CameraAssetUploadSink)?, uploadEnabled: Bool) { + self.uploadSink = uploadSink + self.uploadEnabled = uploadEnabled + } + + /// 启动相机连接。 + func connect() async { + await cameraService.connect() + } + + /// 断开相机连接。 + func disconnect() async { + await cameraService.disconnect() + } + + /// 当前相机连接状态。 + var connectionState: CameraConnectionState { + cameraService.connectionState + } + + /// 设置连接状态变化回调。 + var onConnectionStateChange: ((CameraConnectionState) -> Void)? { + get { nil } + set { + cameraService.onConnectionStateChange = newValue + } + } + + /// 重试所有失败的上传任务。 + func retryFailedUploads() async { + guard uploadEnabled, uploadSink != nil else { return } + for index in tasks.indices where tasks[index].status == .failed { + tasks[index].status = .downloaded + tasks[index].errorMessage = nil + } + notify() + await processUploadQueue() + } + + /// 手动上传指定 asset 列表(拍完再传模式)。 + func uploadAssets(withIDs assetIDs: [String]) async { + guard uploadEnabled, uploadSink != nil else { return } + for assetID in assetIDs { + guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue } + if tasks[index].status == .uploaded { continue } + if tasks[index].localURL == nil { + await downloadAsset(at: index) + } + if tasks[index].status == .downloaded || tasks[index].status == .failed { + tasks[index].status = .downloaded + await uploadTask(at: index) + } + } + } + + /// 同步相机内已有照片到任务列表(不自动上传)。 + func syncExistingPhotos() async { + guard cameraService.connectionState.isConnected else { return } + do { + let assets = try await cameraService.listAssets() + for asset in assets { + await handleNewAsset(asset, skipIfExists: true) + } + } catch { + CameraTetheringLogger.log("syncExistingPhotos 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer) + } + } + + /// 清空所有传输记录及本地文件。 + func clearAllTransfers() { + for task in tasks { + guard let localURL = task.localURL, + FileManager.default.fileExists(atPath: localURL.path) + else { continue } + try? FileManager.default.removeItem(at: localURL) + } + tasks.removeAll() + notify() + } + + /// 按 assetID 查找任务。 + func task(forAssetID assetID: String) -> CameraTransferTask? { + tasks.first { $0.assetID == assetID } + } + + private func handleNewAsset(_ asset: CameraAsset, skipIfExists: Bool) async { + if skipIfExists, + let existing = tasks.first(where: { $0.assetID == asset.id }), + existing.status == .uploaded || existing.status == .downloaded { + return + } + if inFlightAssetIDs.contains(asset.id) { return } + if tasks.contains(where: { $0.assetID == asset.id && $0.status == .downloading }) { return } + + inFlightAssetIDs.insert(asset.id) + defer { inFlightAssetIDs.remove(asset.id) } + + let taskIndex: Int + if let existingIndex = tasks.firstIndex(where: { $0.assetID == asset.id }) { + taskIndex = existingIndex + tasks[taskIndex].status = .downloading + tasks[taskIndex].errorMessage = nil + } else { + let task = CameraTransferTask(assetID: asset.id, filename: asset.filename, status: .downloading) + tasks.insert(task, at: 0) + taskIndex = 0 + } + notify() + + await downloadAsset(at: taskIndex, asset: asset) + + if uploadEnabled, uploadSink != nil, tasks[taskIndex].status == .downloaded { + await processUploadQueue() + } + } + + private func downloadAsset(at index: Int, asset: CameraAsset? = nil) async { + guard tasks.indices.contains(index) else { return } + let assetID = tasks[index].assetID + + do { + let resolvedAsset: CameraAsset + if let asset { + resolvedAsset = asset + } else if let cached = try? await cameraService.listAssets().first(where: { $0.id == assetID }) { + resolvedAsset = cached + } else { + throw CameraServiceError.assetNotFound + } + + let localURL = try await cameraService.downloadAsset(resolvedAsset) + let destination: URL + + if localURL.path.hasPrefix(CameraDownloadStorage.downloadsDirectory.path) { + destination = localURL + } else { + destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename) + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.moveItem(at: localURL, to: destination) + } + + tasks[index].localPath = destination.path + tasks[index].status = .downloaded + tasks[index].progress = 0 + notify() + } catch { + tasks[index].status = .failed + tasks[index].errorMessage = error.localizedDescription + notify() + } + } + + private func processUploadQueue() async { + guard uploadEnabled, uploadSink != nil else { return } + guard !isProcessing else { return } + isProcessing = true + defer { isProcessing = false } + + let pendingIndices = tasks.indices.filter { tasks[$0].status == .downloaded } + for index in pendingIndices { + await uploadTask(at: index) + } + } + + private func uploadTask(at index: Int) async { + guard tasks.indices.contains(index), + let sink = uploadSink, + let localURL = tasks[index].localURL + else { return } + + tasks[index].status = .uploading + tasks[index].errorMessage = nil + notify() + + let fileType = tasks[index].filename.lowercased().hasSuffix(".mp4") ? 1 : 2 + var attempt = 0 + + while attempt < maxRetries { + do { + let remoteURL = try await sink.upload( + localURL: localURL, + fileName: tasks[index].filename, + fileType: fileType, + progress: { [weak self] progress in + Task { @MainActor in + guard let self, self.tasks.indices.contains(index) else { return } + self.tasks[index].progress = progress + self.notify() + } + } + ) + tasks[index].status = .uploaded + tasks[index].remoteURL = remoteURL + tasks[index].progress = 100 + notify() + return + } catch { + attempt += 1 + if attempt >= maxRetries { + tasks[index].status = .failed + tasks[index].errorMessage = error.localizedDescription + notify() + } else { + let delay = UInt64(pow(2.0, Double(attempt))) + try? await Task.sleep(nanoseconds: delay * 1_000_000_000) + } + } + } + } + + private func notify() { + onTasksUpdated?(tasks) + } +} diff --git a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift b/suixinkan/Core/CameraTransfer/CameraTransferTask.swift new file mode 100644 index 0000000..14e559c --- /dev/null +++ b/suixinkan/Core/CameraTransfer/CameraTransferTask.swift @@ -0,0 +1,56 @@ +// +// CameraTransferTask.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 相机传输任务状态。 +enum CameraTransferStatus: String, Codable, Equatable { + case pending + case downloading + case downloaded + case uploading + case uploaded + case failed +} + +/// 相机传输任务,表示从相机下载到上传登记的单条记录。 +struct CameraTransferTask: Identifiable, Equatable, Codable { + let id: UUID + let assetID: String + let filename: String + var localPath: String? + var remoteURL: String? + var status: CameraTransferStatus + var progress: Int + var errorMessage: String? + + init( + id: UUID = UUID(), + assetID: String, + filename: String, + localPath: String? = nil, + remoteURL: String? = nil, + status: CameraTransferStatus = .pending, + progress: Int = 0, + errorMessage: String? = nil + ) { + self.id = id + self.assetID = assetID + self.filename = filename + self.localPath = localPath + self.remoteURL = remoteURL + self.status = status + self.progress = progress + self.errorMessage = errorMessage + } + + /// 本地文件 URL,仅在路径有效时返回。 + var localURL: URL? { + guard let localPath, !localPath.isEmpty else { return nil } + return URL(fileURLWithPath: localPath) + } +} diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index 429f7c0..7f03747 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -21,6 +21,7 @@ struct HomeMenuItem: Equatable, Identifiable { /// 首页可导航目标实体,表示首页菜单能进入的本地页面或占位页面。 enum HomeRoute: Hashable { case profileSpace + case profileSpaceSettings case scenicSelection case permissionApply case permissionApplyStatus @@ -63,6 +64,7 @@ enum HomeRoute: Hashable { case queueManagement case liveManagement case liveAlbum + case travelAlbumEntry case operatingArea case pilotCertification case modulePlaceholder(uri: String, title: String) diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index c2fcb03..5c40076 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -35,6 +35,7 @@ enum HomeMenuRouter { "sample_upload": "上传样片", "live_stream_management": "直播管理", "live_album": "直播相册", + "travel_album": "新增相册", "scenicselection": "景区选择", "scenicapplication": "景区申请", "permission_apply": "权限申请", @@ -76,7 +77,9 @@ enum HomeMenuRouter { return .orderPush(.writeOffList) case "photographer_stats": return .tab(.statistics) - case "space_settings", "basic_info": + case "space_settings": + return .destination(.profileSpaceSettings) + case "basic_info": return .destination(.profileSpace) case "system_settings": return .destination(.settings) @@ -146,6 +149,8 @@ enum HomeMenuRouter { return .destination(.liveManagement) case "live_album": return .destination(.liveAlbum) + case "travel_album", "task_new_entry": + return .destination(.travelAlbumEntry) case "operating-area": return .destination(.operatingArea) case "pilot_cert": @@ -176,6 +181,7 @@ enum HomeMenuRouter { "basic_info", "album_list", "album_trailer", + "travel_album", "wallet", "payment_collection", "payment_qr", diff --git a/suixinkan/Features/Home/Views/HomeIconCatalog.swift b/suixinkan/Features/Home/Views/HomeIconCatalog.swift index 3f10e3c..ed1b944 100644 --- a/suixinkan/Features/Home/Views/HomeIconCatalog.swift +++ b/suixinkan/Features/Home/Views/HomeIconCatalog.swift @@ -48,6 +48,8 @@ enum HomeIconCatalog { "checkmark.seal.fill" case "live_album": "play.rectangle.on.rectangle.fill" + case "travel_album", "task_new_entry": + "photo.on.rectangle.angled" case "pm", "pm_manager", "project_edit": "circle.grid.2x2.fill" case "location_report", "location_report_history": diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 9329635..6653fc9 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -14,6 +14,8 @@ extension HomeRoute { switch self { case .profileSpace: ProfileView() + case .profileSpaceSettings: + ProfileSpaceView() case .scenicSelection: ScenicSelectionView() case .permissionApply: @@ -102,6 +104,8 @@ extension HomeRoute { LiveManagementView() case .liveAlbum: LiveAlbumView() + case .travelAlbumEntry: + TravelAlbumEntryView() case .operatingArea: OperatingAreaView() case .pilotCertification: diff --git a/suixinkan/Features/Profile/API/ProfileAPI.swift b/suixinkan/Features/Profile/API/ProfileAPI.swift index 9aacfff..2afc292 100644 --- a/suixinkan/Features/Profile/API/ProfileAPI.swift +++ b/suixinkan/Features/Profile/API/ProfileAPI.swift @@ -8,6 +8,22 @@ import Foundation import Combine +@MainActor +/// 个人空间配置 API 协议,便于页面 ViewModel 使用测试替身。 +protocol ProfileSpaceServing { + /// 拉取当前景区下的个人空间配置。 + func profileSpaceInfo(scenicId: Int) async throws -> ProfileSpaceInfoResponse + + /// 更新当前景区下的个人空间配置。 + func updateProfileSpaceInfo(_ request: UpdateProfileSpaceRequest) async throws + + /// 更新用户昵称、密码或头像地址。 + func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws + + /// 单独更新用户头像 URL。 + func updateUserAvatarURL(_ fileURL: String) async throws +} + @MainActor /// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。 final class ProfileAPI { @@ -71,6 +87,28 @@ final class ProfileAPI { ) } + /// 拉取当前景区下的个人空间配置。 + func profileSpaceInfo(scenicId: Int) async throws -> ProfileSpaceInfoResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/profile/info", + queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")] + ) + ) + } + + /// 更新当前景区下的个人空间配置。 + func updateProfileSpaceInfo(_ request: UpdateProfileSpaceRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/profile/info-update", + body: request + ) + ) + } + /// 发送实名认证短信验证码。 func realNameSmsVerifyCode() async throws { let _: EmptyPayload = try await client.send( @@ -95,3 +133,4 @@ final class ProfileAPI { } extension ProfileAPI: UserProfileServing {} +extension ProfileAPI: ProfileSpaceServing {} diff --git a/suixinkan/Features/Profile/Profile.md b/suixinkan/Features/Profile/Profile.md index 76d1ffd..70e4387 100644 --- a/suixinkan/Features/Profile/Profile.md +++ b/suixinkan/Features/Profile/Profile.md @@ -4,6 +4,8 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。 +首页“空间设置”入口对应的是摄影师当前景区下的个人空间配置,已拆到 `ProfileSpace` 模块;`Profile` 模块只保留账号级个人资料能力。 + 该模块聚焦个人资料业务: - 拉取用户基础资料。 - 拉取实名认证信息。 @@ -35,6 +37,8 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口, - `UpdateInfoRequest`:昵称、密码或头像更新请求体。 - `RealNameAuthRequest`:实名认证提交请求体。 +`basic_info` 首页权限入口继续进入 `ProfileView`;`space_settings` 进入 `ProfileSpaceView`,两者不共用页面状态。 + ## 加载流程 1. `ProfileView.task` 进入页面时调用 `reloadProfile()`。 diff --git a/suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift b/suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift new file mode 100644 index 0000000..261467a --- /dev/null +++ b/suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift @@ -0,0 +1,254 @@ +// +// ProfileSpaceModels.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 摄影师个人空间配置响应,表示当前景区下的公开展示资料和接单配置。 +struct ProfileSpaceInfoResponse: Decodable, Equatable { + let id: Int + let scenicId: Int + let photogUid: Int + let realName: String + let nickname: String + let avatar: String + let scenicCertification: [String] + let description: String + let attrLabel: [String] + let shootLabel: [String] + let cameraDevice: [String] + let businessStartTime: String + let businessEndTime: String + let holidayStartTime: String + let holidayEndTime: String + let businessRange: [Int] + let acceptOrderStatus: Int + let schedule: [String: [ScheduleItem]] + + /// 响应 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case photogUid = "photog_uid" + case realName = "real_name" + case nickname + case avatar + case scenicCertification = "scenic_certification" + case description + case attrLabel = "attr_label" + case shootLabel = "shoot_label" + case cameraDevice = "camera_device" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + case holidayStartTime = "holiday_start_time" + case holidayEndTime = "holiday_end_time" + case businessRange = "business_range" + case acceptOrderStatus = "accept_order_status" + case schedule + } + + /// 宽松解码后端资料字段,兼容 null、数字字符串和缺失数组。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProfileSpaceLossyInt(forKey: .id) ?? 0 + scenicId = try container.decodeProfileSpaceLossyInt(forKey: .scenicId) ?? 0 + photogUid = try container.decodeProfileSpaceLossyInt(forKey: .photogUid) ?? 0 + realName = try container.decodeProfileSpaceLossyString(forKey: .realName) + nickname = try container.decodeProfileSpaceLossyString(forKey: .nickname) + avatar = try container.decodeProfileSpaceLossyString(forKey: .avatar) + scenicCertification = try container.decodeProfileSpaceStringArray(forKey: .scenicCertification) + description = try container.decodeProfileSpaceLossyString(forKey: .description) + attrLabel = try container.decodeProfileSpaceStringArray(forKey: .attrLabel) + shootLabel = try container.decodeProfileSpaceStringArray(forKey: .shootLabel) + cameraDevice = try container.decodeProfileSpaceStringArray(forKey: .cameraDevice) + businessStartTime = try container.decodeProfileSpaceLossyString(forKey: .businessStartTime) + businessEndTime = try container.decodeProfileSpaceLossyString(forKey: .businessEndTime) + holidayStartTime = try container.decodeProfileSpaceLossyString(forKey: .holidayStartTime) + holidayEndTime = try container.decodeProfileSpaceLossyString(forKey: .holidayEndTime) + businessRange = try container.decodeProfileSpaceIntArray(forKey: .businessRange) + acceptOrderStatus = try container.decodeProfileSpaceLossyInt(forKey: .acceptOrderStatus) ?? 0 + schedule = (try? container.decodeIfPresent([String: [ScheduleItem]].self, forKey: .schedule)) ?? [:] + } +} + +/// 摄影师个人空间配置更新请求。 +struct UpdateProfileSpaceRequest: Encodable, Equatable { + let scenicId: String + let description: String + let cameraDevice: [String] + let attrLabel: [String] + let shootLabel: [String] + let businessStartTime: String + let businessEndTime: String + let holidayStartTime: String + let holidayEndTime: String + let businessRange: [Int] + let acceptOrderStatus: Int + + /// 请求 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case description + case cameraDevice = "camera_device" + case attrLabel = "attr_label" + case shootLabel = "shoot_label" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + case holidayStartTime = "holiday_start_time" + case holidayEndTime = "holiday_end_time" + case businessRange = "business_range" + case acceptOrderStatus = "accept_order_status" + } +} + +/// 个人空间接单状态枚举,承接后端 1-4 状态值。 +enum ProfileSpaceOrderStatus: Int, CaseIterable, Identifiable { + case accepting = 1 + case paused = 2 + case full = 3 + case offline = 4 + + var id: Int { rawValue } + + /// 状态展示文案。 + var title: String { + switch self { + case .accepting: "可接单" + case .paused: "暂停接单" + case .full: "已约满" + case .offline: "下线" + } + } +} + +/// 个人空间配置草稿,用于比较是否有未保存改动。 +struct ProfileSpaceEditableSnapshot: Equatable { + var nickname: String + var description: String + var attrLabel: [String] + var shootLabel: [String] + var cameraDevice: [String] + var businessStartTime: Date? + var businessEndTime: Date? + var holidayStartTime: Date? + var holidayEndTime: Date? + var acceptOrderStatus: Int + var avatarURL: String +} + +/// 个人空间配置表单校验错误。 +enum ProfileSpaceValidationError: LocalizedError, Equatable { + case missingScenic + case emptyNickname + case emptyLabel + case duplicateLabel + case labelTooLong + case labelLimit + case missingWorkdayStart + case missingWorkdayEnd + case missingHolidayStart + case missingHolidayEnd + case invalidWorkdayTime + case invalidHolidayTime + + var errorDescription: String? { + switch self { + case .missingScenic: "当前缺少景区信息" + case .emptyNickname: "请输入昵称" + case .emptyLabel: "标签不能为空" + case .duplicateLabel: "标签已存在" + case .labelTooLong: "标签最多20个字符" + case .labelLimit: "最多8个标签" + case .missingWorkdayStart: "请输入工作日开始时间" + case .missingWorkdayEnd: "请输入工作日结束时间" + case .missingHolidayStart: "请输入节假日开始时间" + case .missingHolidayEnd: "请输入节假日结束时间" + case .invalidWorkdayTime: "工作日开始时间必须早于结束时间" + case .invalidHolidayTime: "节假日开始时间必须早于结束时间" + } + } +} + +extension Date { + /// 将日期格式化为后端需要的 HH:mm:ss 时间文本。 + var profileSpaceTimeText: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: self) + } + + /// 将日期格式化为空间配置页面展示的 HH:mm 文本。 + var profileSpaceHourMinuteText: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "HH:mm" + return formatter.string(from: self) + } + + /// 根据 HH:mm:ss 或 HH:mm 文本生成仅含时间部分的 Date。 + static func profileSpaceTime(from text: String) -> Date? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "00:00:00" else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = trimmed.count == 5 ? "HH:mm" : "HH:mm:ss" + return formatter.date(from: trimmed) + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeProfileSpaceLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "1" : "0" + } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为整数。 + func decodeProfileSpaceLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(text) ?? Double(text).map(Int.init) + } + return nil + } + + /// 宽松解码字符串数组,过滤空白项。 + func decodeProfileSpaceStringArray(forKey key: Key) throws -> [String] { + guard let values = try? decodeIfPresent([String].self, forKey: key) else { return [] } + return values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + /// 宽松解码整数数组。 + func decodeProfileSpaceIntArray(forKey key: Key) throws -> [Int] { + if let values = try? decodeIfPresent([Int].self, forKey: key) { + return values + } + if let values = try? decodeIfPresent([String].self, forKey: key) { + return values.compactMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + } + return [] + } +} diff --git a/suixinkan/Features/ProfileSpace/ProfileSpace.md b/suixinkan/Features/ProfileSpace/ProfileSpace.md new file mode 100644 index 0000000..18ac4e4 --- /dev/null +++ b/suixinkan/Features/ProfileSpace/ProfileSpace.md @@ -0,0 +1,58 @@ +# ProfileSpace 模块业务逻辑 + +## 模块职责 + +ProfileSpace 模块负责首页“空间设置”入口,对齐 Android `space_settings -> ProfileInfoRoute` 的个人空间配置能力。 + +该模块聚焦摄影师在当前景区下的公开展示和接单配置: +- 拉取个人空间资料。 +- 修改头像、昵称和简介。 +- 管理个人标签、拍摄说明标签和相机设备。 +- 配置工作日与法定节假日营业时间。 +- 配置接单状态。 +- 展示未来 7 天日程,并支持添加、删除和拨打客户电话。 + +相册管理不属于本模块,仍由 Assets/相册模块承接。 + +## 核心对象 + +- `ProfileSpaceView`:空间配置页 UI,负责资料展示、表单输入、日程展示和保存入口。 +- `ProfileSpaceViewModel`:维护编辑草稿、原始快照、脏状态、校验和提交逻辑。 +- `ProfileSpaceInfoResponse`:空间配置详情响应。 +- `UpdateProfileSpaceRequest`:空间配置更新请求。 +- `ProfileAPI`:封装空间配置读取和更新接口,同时复用用户昵称、头像更新接口。 +- `ScheduleAPI`:复用已有排班新增和删除接口。 +- `OSSUploadService`:复用头像上传能力。 + +## 加载流程 + +1. 首页点击 `space_settings` 后进入 `ProfileSpaceView`。 +2. 页面从 `AccountContext.currentScenic` 读取当前景区 ID。 +3. `ProfileSpaceViewModel.load` 调用 `ProfileAPI.profileSpaceInfo`。 +4. 接口成功后回填头像、姓名、昵称、简介、标签、设备、营业时间、接单状态和日程 map。 +5. ViewModel 保存一份 `ProfileSpaceEditableSnapshot`,用于判断保存按钮是否可用。 + +缺少当前景区时,页面展示缺少景区空态,不发起空间配置接口。 + +## 保存流程 + +1. 用户修改表单后,ViewModel 根据当前草稿和原始快照判断 `hasChanges`。 +2. 点击保存时先校验: + - 昵称不能为空。 + - 标签和拍摄说明均最多 8 个,单个最多 20 字,禁止空值和重复。 + - 四个营业时间必须填写。 + - 工作日、节假日开始时间必须早于结束时间。 +3. 如头像有变化,先调用 `OSSUploadService.uploadUserAvatar` 上传,再调用 `ProfileAPI.updateUserAvatarURL` 回写头像。 +4. 如昵称有变化,调用 `ProfileAPI.updateUserInfo(nickname:)`。 +5. 调用 `ProfileAPI.updateProfileSpaceInfo` 保存空间配置。 +6. 保存成功后刷新原始快照,清除头像暂存,并同步 `AccountContext` 和账号快照中的昵称/头像展示信息。 + +保存失败时保留当前草稿和未保存状态,页面展示错误信息。 + +## 日程流程 + +空间配置页展示从空间详情接口返回的 `schedule` map,并在页面内显示从今天起连续 7 天的日程。 + +添加日程复用现有 `ScheduleAddView`,提交逻辑仍由 Schedule 模块负责。页面返回后会重新加载空间配置。 + +删除日程调用 `ScheduleAPI.deleteSchedule`,成功后重新加载空间配置,保持日程来源一致。 diff --git a/suixinkan/Features/ProfileSpace/ViewModels/ProfileSpaceViewModel.swift b/suixinkan/Features/ProfileSpace/ViewModels/ProfileSpaceViewModel.swift new file mode 100644 index 0000000..9b37889 --- /dev/null +++ b/suixinkan/Features/ProfileSpace/ViewModels/ProfileSpaceViewModel.swift @@ -0,0 +1,302 @@ +// +// ProfileSpaceViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation +import Combine + +@MainActor +/// 个人空间配置 ViewModel,负责加载资料、维护编辑草稿、校验并提交空间配置。 +final class ProfileSpaceViewModel: ObservableObject { + @Published private(set) var loading = false + @Published private(set) var saving = false + @Published var errorMessage: String? + @Published var nickname = "" + @Published private(set) var realName = "" + @Published private(set) var avatarURL = "" + @Published private(set) var scenicCertification: [String] = [] + @Published var introduction = "" + @Published var attrLabels: [String] = [] + @Published var shootLabels: [String] = [] + @Published var cameraDevices: [String] = [] + @Published var businessStartTime: Date? + @Published var businessEndTime: Date? + @Published var holidayStartTime: Date? + @Published var holidayEndTime: Date? + @Published var acceptOrderStatus = 0 + @Published private(set) var scheduleMap: [String: [ScheduleItem]] = [:] + @Published private(set) var pendingAvatarData: Data? + @Published private(set) var pendingAvatarFileName: String? + @Published private(set) var avatarUploadProgress: Int? + + private var originalSnapshot: ProfileSpaceEditableSnapshot? + + /// 当前页面是否存在未保存改动。 + var hasChanges: Bool { + guard let originalSnapshot else { return pendingAvatarData != nil } + return editableSnapshot() != originalSnapshot || pendingAvatarData != nil + } + + /// 使用当前景区加载个人空间配置。 + func load(api: any ProfileSpaceServing, scenicId: Int?) async { + guard let scenicId else { + resetForMissingScenic() + return + } + guard !loading else { return } + loading = true + errorMessage = nil + defer { loading = false } + do { + let response = try await api.profileSpaceInfo(scenicId: scenicId) + apply(response) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 处理用户选择的头像图片,成功后暂存在内存中等待保存。 + func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws { + let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp) + pendingAvatarData = processed.data + pendingAvatarFileName = processed.fileName + } + + /// 添加个人标签。 + func addAttrLabel(_ label: String) throws { + attrLabels = try adding(label, to: attrLabels) + } + + /// 添加拍摄说明标签。 + func addShootLabel(_ label: String) throws { + shootLabels = try adding(label, to: shootLabels) + } + + /// 删除个人标签。 + func removeAttrLabel(_ label: String) { + attrLabels.removeAll { $0 == label } + } + + /// 删除拍摄说明标签。 + func removeShootLabel(_ label: String) { + shootLabels.removeAll { $0 == label } + } + + /// 添加相机设备名称。 + func addCameraDevice(_ device: String) { + let value = device.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + cameraDevices.append(value) + } + + /// 删除相机设备名称。 + func removeCameraDevice(_ device: String) { + cameraDevices.removeAll { $0 == device } + } + + /// 保存个人空间配置、昵称和待上传头像。 + func save(api: any ProfileSpaceServing, uploader: any OSSUploadServing, scenicId: Int?) async throws { + guard let scenicId else { + errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription + throw ProfileSpaceValidationError.missingScenic + } + do { + let request = try makeUpdateRequest(scenicId: scenicId) + guard !saving else { return } + saving = true + errorMessage = nil + avatarUploadProgress = pendingAvatarData == nil ? nil : 1 + defer { + saving = false + avatarUploadProgress = nil + } + + let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId) + if let uploadedAvatarURL { + try await api.updateUserAvatarURL(uploadedAvatarURL) + } + + let nextNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + if nextNickname != originalSnapshot?.nickname { + try await api.updateUserInfo(nickname: nextNickname, password: nil, avatar: nil) + } + + try await api.updateProfileSpaceInfo(request) + if let uploadedAvatarURL { + avatarURL = uploadedAvatarURL + } + nickname = nextNickname + pendingAvatarData = nil + pendingAvatarFileName = nil + originalSnapshot = editableSnapshot() + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + /// 删除日程并重新加载个人空间配置。 + func deleteSchedule( + item: ScheduleItem, + profileAPI: any ProfileSpaceServing, + scheduleAPI: any ScheduleServing, + scenicId: Int? + ) async { + guard let scenicId else { + errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription + return + } + do { + try await scheduleAPI.deleteSchedule(id: item.id) + await load(api: profileAPI, scenicId: scenicId) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 生成全局账号资料兜底,用于保存昵称和头像后同步 AccountContext。 + func accountProfileFallback(_ fallback: AccountProfile?) -> AccountProfile? { + AccountProfile( + userId: fallback?.userId ?? "", + displayName: nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? (fallback?.displayName ?? "") : nickname, + phone: fallback?.phone, + avatarURL: avatarURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fallback?.avatarURL : avatarURL + ) + } + + /// 构造空间配置更新请求并执行表单校验。 + func makeUpdateRequest(scenicId: Int) throws -> UpdateProfileSpaceRequest { + let nextNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !nextNickname.isEmpty else { + throw ProfileSpaceValidationError.emptyNickname + } + guard let businessStartTime else { + throw ProfileSpaceValidationError.missingWorkdayStart + } + guard let businessEndTime else { + throw ProfileSpaceValidationError.missingWorkdayEnd + } + guard let holidayStartTime else { + throw ProfileSpaceValidationError.missingHolidayStart + } + guard let holidayEndTime else { + throw ProfileSpaceValidationError.missingHolidayEnd + } + guard seconds(in: businessStartTime) < seconds(in: businessEndTime) else { + throw ProfileSpaceValidationError.invalidWorkdayTime + } + guard seconds(in: holidayStartTime) < seconds(in: holidayEndTime) else { + throw ProfileSpaceValidationError.invalidHolidayTime + } + + return UpdateProfileSpaceRequest( + scenicId: "\(scenicId)", + description: introduction.trimmingCharacters(in: .whitespacesAndNewlines), + cameraDevice: normalized(cameraDevices), + attrLabel: normalized(attrLabels), + shootLabel: normalized(shootLabels), + businessStartTime: businessStartTime.profileSpaceTimeText, + businessEndTime: businessEndTime.profileSpaceTimeText, + holidayStartTime: holidayStartTime.profileSpaceTimeText, + holidayEndTime: holidayEndTime.profileSpaceTimeText, + businessRange: [], + acceptOrderStatus: acceptOrderStatus + ) + } + + /// 将后端响应写入页面状态。 + private func apply(_ response: ProfileSpaceInfoResponse) { + realName = response.realName + nickname = response.nickname + avatarURL = response.avatar + scenicCertification = response.scenicCertification + introduction = response.description + attrLabels = response.attrLabel + shootLabels = response.shootLabel + cameraDevices = response.cameraDevice + businessStartTime = Date.profileSpaceTime(from: response.businessStartTime) + businessEndTime = Date.profileSpaceTime(from: response.businessEndTime) + holidayStartTime = Date.profileSpaceTime(from: response.holidayStartTime) + holidayEndTime = Date.profileSpaceTime(from: response.holidayEndTime) + acceptOrderStatus = response.acceptOrderStatus + scheduleMap = response.schedule + pendingAvatarData = nil + pendingAvatarFileName = nil + originalSnapshot = editableSnapshot() + } + + /// 缺少景区时清空页面业务状态。 + private func resetForMissingScenic() { + loading = false + errorMessage = ProfileSpaceValidationError.missingScenic.localizedDescription + realName = "" + nickname = "" + avatarURL = "" + scenicCertification = [] + introduction = "" + attrLabels = [] + shootLabels = [] + cameraDevices = [] + businessStartTime = nil + businessEndTime = nil + holidayStartTime = nil + holidayEndTime = nil + acceptOrderStatus = 0 + scheduleMap = [:] + originalSnapshot = nil + } + + /// 返回当前编辑态快照。 + private func editableSnapshot() -> ProfileSpaceEditableSnapshot { + ProfileSpaceEditableSnapshot( + nickname: nickname.trimmingCharacters(in: .whitespacesAndNewlines), + description: introduction.trimmingCharacters(in: .whitespacesAndNewlines), + attrLabel: normalized(attrLabels), + shootLabel: normalized(shootLabels), + cameraDevice: normalized(cameraDevices), + businessStartTime: businessStartTime, + businessEndTime: businessEndTime, + holidayStartTime: holidayStartTime, + holidayEndTime: holidayEndTime, + acceptOrderStatus: acceptOrderStatus, + avatarURL: avatarURL + ) + } + + /// 添加标签并执行数量、长度和重复校验。 + private func adding(_ label: String, to labels: [String]) throws -> [String] { + let value = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { throw ProfileSpaceValidationError.emptyLabel } + guard value.count <= 20 else { throw ProfileSpaceValidationError.labelTooLong } + guard labels.count < 8 else { throw ProfileSpaceValidationError.labelLimit } + guard !labels.contains(value) else { throw ProfileSpaceValidationError.duplicateLabel } + return labels + [value] + } + + /// 清洗字符串数组,移除空白项。 + private func normalized(_ values: [String]) -> [String] { + values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + /// 返回日期的当天秒数,用于只比较时间部分。 + private func seconds(in date: Date) -> Int { + let components = Calendar.current.dateComponents([.hour, .minute, .second], from: date) + return (components.hour ?? 0) * 3600 + (components.minute ?? 0) * 60 + (components.second ?? 0) + } + + /// 上传待保存头像;没有待上传图片时返回 nil。 + private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? { + guard let pendingAvatarData else { return nil } + let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg" + return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in + Task { @MainActor in + self?.avatarUploadProgress = progress + } + } + } +} diff --git a/suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift b/suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift new file mode 100644 index 0000000..4997263 --- /dev/null +++ b/suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift @@ -0,0 +1,733 @@ +// +// ProfileSpaceView.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import PhotosUI +import SwiftUI +import UIKit + +/// 个人空间配置页面,承接首页“空间设置”入口。 +struct ProfileSpaceView: View { + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var toastCenter: ToastCenter + @Environment(\.profileAPI) private var profileAPI + @Environment(\.scheduleAPI) private var scheduleAPI + @Environment(\.ossUploadService) private var ossUploadService + @Environment(\.accountSnapshotStore) private var snapshotStore + @Environment(\.globalLoading) private var globalLoading + @Environment(\.openURL) private var openURL + + @StateObject private var viewModel = ProfileSpaceViewModel() + @State private var pickedAvatarItem: PhotosPickerItem? + @State private var attrInput = "" + @State private var shootInput = "" + @State private var deviceInput = "" + @State private var selectedDate = Date() + @State private var deleteTarget: ProfileSpaceDeleteTarget? + @State private var appearedOnce = false + + private var scenicId: Int? { + accountContext.currentScenic?.id + } + + private var selectedDateKey: String { + selectedDate.scheduleDayText + } + + private var selectedSchedules: [ScheduleItem] { + viewModel.scheduleMap[selectedDateKey] ?? [] + } + + var body: some View { + Group { + if scenicId == nil { + AppContentUnavailableView("缺少景区", systemImage: "person.crop.circle.badge.exclamationmark", description: Text("请选择景区后再配置个人空间。")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + } else { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + profileCard + labelCard + businessTimeCard + deviceCard + orderStatusCard + scheduleCard + } + .padding(AppMetrics.Spacing.medium) + .padding(.bottom, 78) + } + .background(Color(hex: 0xF5F7FA).ignoresSafeArea()) + .refreshable { await reload(showLoading: false) } + .safeAreaInset(edge: .bottom) { + saveBar + } + } + } + .navigationTitle("个人空间配置") + .navigationBarTitleDisplayMode(.inline) + .task(id: scenicId) { + await reload(showLoading: true) + } + .onAppear { + guard appearedOnce else { + appearedOnce = true + return + } + Task { await reload(showLoading: false) } + } + .onChange(of: pickedAvatarItem) { item in + Task { await prepareAvatarImage(from: item) } + } + .alert(item: $deleteTarget) { target in + Alert( + title: Text(target.title), + message: Text(target.message), + primaryButton: .destructive(Text("删除")) { + delete(target) + }, + secondaryButton: .cancel(Text("取消")) + ) + } + } + + private var profileCard: some View { + sectionCard { + HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) { + avatarPicker + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("姓名: \(viewModel.realName.isEmpty ? "--" : viewModel.realName)") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + TextField("请输入昵称", text: $viewModel.nickname) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .appInputFieldStyle(cornerRadius: 8, minHeight: 42) + TextField("请输入简介", text: $viewModel.introduction, axis: .vertical) + .lineLimit(2...5) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .appInputFieldStyle(cornerRadius: 8, minHeight: 78) + certificationTags + } + } + } + } + + private var avatarPicker: some View { + PhotosPicker(selection: $pickedAvatarItem, matching: .images) { + avatarPreview + .frame(width: 92, height: 92) + .clipShape(Circle()) + .overlay { + Circle().stroke(.white, lineWidth: 3) + } + .overlay(alignment: .bottomTrailing) { + Image(systemName: "camera.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(AppDesign.primary, in: Circle()) + .overlay { Circle().stroke(.white, lineWidth: 2) } + } + .overlay { + if let progress = viewModel.avatarUploadProgress { + ZStack { + Circle().fill(.black.opacity(0.34)) + Text("\(progress)%") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(.white) + } + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("头像") + } + + @ViewBuilder + private var avatarPreview: some View { + if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + RemoteAvatarImage(urlString: viewModel.avatarURL) + } + } + + @ViewBuilder + private var certificationTags: some View { + if !viewModel.scenicCertification.isEmpty { + FlowLayout(spacing: 6, lineSpacing: 6) { + ForEach(viewModel.scenicCertification, id: \.self) { tag in + Text(tag) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.warning) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color(hex: 0xFFF0E2), in: RoundedRectangle(cornerRadius: 4)) + } + } + } + } + + private var labelCard: some View { + sectionCard { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("标签") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + chipEditor(labels: viewModel.attrLabels, input: $attrInput, placeholder: "添加标签") { value in + try viewModel.addAttrLabel(value) + } remove: { value in + deleteTarget = .attrLabel(value) + } + + Text("拍摄说明") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .padding(.top, AppMetrics.Spacing.xSmall) + chipEditor(labels: viewModel.shootLabels, input: $shootInput, placeholder: "添加拍摄说明") { value in + try viewModel.addShootLabel(value) + } remove: { value in + deleteTarget = .shootLabel(value) + } + + Text("每个标签最多20个字符,最多8个标签") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.placeholder) + } + } + } + + private var businessTimeCard: some View { + sectionCard { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + sectionTitle("营业时间") + timeRow(title: "工作日营业时间", start: $viewModel.businessStartTime, end: $viewModel.businessEndTime) + timeRow(title: "法定节假日营业时间", start: $viewModel.holidayStartTime, end: $viewModel.holidayEndTime) + } + } + } + + private var deviceCard: some View { + sectionCard { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + sectionTitle("相机设备") + FlowLayout(spacing: 8, lineSpacing: 8) { + ForEach(viewModel.cameraDevices, id: \.self) { device in + removableChip(title: device, tint: AppDesign.textSecondary) { + deleteTarget = .device(device) + } + } + } + HStack(spacing: AppMetrics.Spacing.small) { + TextField("请输入设备名", text: $deviceInput) + .appInputFieldStyle(cornerRadius: 8, minHeight: 42) + Button { + viewModel.addCameraDevice(deviceInput) + deviceInput = "" + } label: { + Image(systemName: "plus") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .frame(width: 44, height: 42) + } + .buttonStyle(.borderedProminent) + .disabled(deviceInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + + private var orderStatusCard: some View { + sectionCard { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + sectionTitle("接单状态") + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: AppMetrics.Spacing.small), count: 2), spacing: AppMetrics.Spacing.small) { + ForEach(ProfileSpaceOrderStatus.allCases) { status in + Button { + viewModel.acceptOrderStatus = status.rawValue + } label: { + Text(status.title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(viewModel.acceptOrderStatus == status.rawValue ? .white : AppDesign.textSecondary) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background( + viewModel.acceptOrderStatus == status.rawValue ? AppDesign.primary : Color.white, + in: RoundedRectangle(cornerRadius: 8) + ) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(viewModel.acceptOrderStatus == status.rawValue ? AppDesign.primary : Color(hex: 0xD7DEE8), lineWidth: 1) + } + } + .buttonStyle(.plain) + } + } + } + } + } + + private var scheduleCard: some View { + sectionCard { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + HStack { + sectionTitle("日程安排") + Spacer() + NavigationLink(value: AppRoute.home(.scheduleAdd)) { + Label("添加日程", systemImage: "plus") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + } + } + weekSelector + HStack { + Text(selectedDate.scheduleDayText) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + } + if selectedSchedules.isEmpty { + AppContentUnavailableView("该日无日程", systemImage: "calendar") + .frame(minHeight: 120) + } else { + VStack(spacing: AppMetrics.Spacing.small) { + ForEach(selectedSchedules) { item in + scheduleItemCard(item) + } + } + } + } + } + } + + private var weekSelector: some View { + HStack(spacing: 0) { + ForEach(nextSevenDays, id: \.scheduleDayText) { date in + Button { + selectedDate = date + } label: { + VStack(spacing: AppMetrics.Spacing.xSmall) { + Text(weekdayText(for: date)) + .font(.system(size: AppMetrics.FontSize.caption)) + Text(dayText(for: date)) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .frame(width: 34, height: 34) + .background(isSelected(date) ? AppDesign.primary : Color(hex: 0xF3F5F8), in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(isSelected(date) ? .white : AppDesign.textSecondary) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + } + } + + private var saveBar: some View { + VStack(spacing: 0) { + if let error = viewModel.errorMessage { + Text(error) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(Color(hex: 0xE5484D)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, AppMetrics.Spacing.medium) + .padding(.top, AppMetrics.Spacing.small) + } + Button { + Task { await save() } + } label: { + HStack { + if viewModel.saving { + ProgressView() + .tint(.white) + } + Text(viewModel.saving ? "保存中..." : "保存") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + .background(viewModel.hasChanges && !viewModel.saving ? AppDesign.primary : Color(hex: 0xB6BECA), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + } + .buttonStyle(.plain) + .disabled(!viewModel.hasChanges || viewModel.saving) + .padding(AppMetrics.Spacing.medium) + } + .background(.white) + .overlay(alignment: .top) { + Rectangle() + .fill(Color(hex: 0xE5E7EB)) + .frame(height: 0.5) + } + } + + /// 构造通用白色分组卡片。 + private func sectionCard(@ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 0) { + content() + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 构造分组标题。 + private func sectionTitle(_ title: String) -> some View { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + } + + /// 构造标签编辑器。 + private func chipEditor( + labels: [String], + input: Binding, + placeholder: String, + add: @escaping (String) throws -> Void, + remove: @escaping (String) -> Void + ) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + FlowLayout(spacing: 8, lineSpacing: 8) { + ForEach(labels, id: \.self) { label in + removableChip(title: label, tint: AppDesign.primary) { + remove(label) + } + } + } + HStack(spacing: AppMetrics.Spacing.small) { + TextField(placeholder, text: input) + .appInputFieldStyle(cornerRadius: 8, minHeight: 42) + Button { + do { + try add(input.wrappedValue) + input.wrappedValue = "" + } catch { + viewModel.errorMessage = error.localizedDescription + } + } label: { + Text("添加") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .frame(width: 56, height: 42) + } + .buttonStyle(.borderedProminent) + .disabled(input.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + /// 构造可删除标签。 + private func removableChip(title: String, tint: Color, onRemove: @escaping () -> Void) -> some View { + HStack(spacing: 4) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + } + .buttonStyle(.plain) + } + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 5)) + } + + /// 构造营业时间行。 + private func timeRow(title: String, start: Binding, end: Binding) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + HStack(spacing: AppMetrics.Spacing.small) { + optionalTimePicker("开始", selection: start) + Text("至") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + optionalTimePicker("结束", selection: end) + } + } + } + + /// 构造可为空的时间选择器。 + private func optionalTimePicker(_ title: String, selection: Binding) -> some View { + VStack(alignment: .leading, spacing: 4) { + DatePicker( + title, + selection: Binding( + get: { selection.wrappedValue ?? defaultTime }, + set: { selection.wrappedValue = $0 } + ), + displayedComponents: .hourAndMinute + ) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) + Text(selection.wrappedValue?.profileSpaceHourMinuteText ?? "未设置") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(selection.wrappedValue == nil ? Color(hex: 0xE5484D) : AppDesign.textSecondary) + } + } + + /// 构造日程卡片。 + private func scheduleItemCard(_ item: ScheduleItem) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + HStack(spacing: AppMetrics.Spacing.small) { + Text("\(item.startTime) - \(item.endTime) \(item.name)") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + Spacer() + Button(role: .destructive) { + deleteTarget = .schedule(item) + } label: { + Text("删除") + .font(.system(size: AppMetrics.FontSize.subheadline)) + } + } + if let orderNumber = item.orderNumber, !orderNumber.isEmpty { + scheduleText("订单号: \(orderNumber)") + } + if let phone = item.userPhone, !phone.isEmpty { + HStack(spacing: AppMetrics.Spacing.medium) { + scheduleText("客户手机号: \(maskedPhone(phone))") + Button("拨打") { + if let url = URL(string: "tel:\(phone)") { + openURL(url) + } + } + .font(.system(size: AppMetrics.FontSize.subheadline)) + } + } + if !item.remark.isEmpty { + scheduleText("备注信息: \(item.remark)") + } + } + .padding(AppMetrics.Spacing.small) + .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) + } + + /// 构造日程辅助文本。 + private func scheduleText(_ text: String) -> some View { + Text(text) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(2) + } + + /// 重新加载页面数据。 + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading && scenicId != nil) { + await viewModel.load(api: profileAPI, scenicId: scenicId) + } + } + + /// 读取 PhotosPicker 选中的头像图片。 + private func prepareAvatarImage(from item: PhotosPickerItem?) async { + guard let item else { return } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { return } + try viewModel.prepareAvatarImage(data: data) + } catch { + viewModel.errorMessage = error.localizedDescription + } + } + + /// 保存当前空间配置。 + private func save() async { + do { + try await globalLoading.withLoading { + try await viewModel.save(api: profileAPI, uploader: ossUploadService, scenicId: scenicId) + } + if let profile = viewModel.accountProfileFallback(accountContext.profile) { + accountContext.replaceProfile(profile) + saveSnapshotProfile(profile) + } + toastCenter.show("保存成功") + } catch { + viewModel.errorMessage = error.localizedDescription + toastCenter.show(error.localizedDescription) + } + } + + /// 执行删除确认目标。 + private func delete(_ target: ProfileSpaceDeleteTarget) { + switch target { + case .attrLabel(let label): + viewModel.removeAttrLabel(label) + case .shootLabel(let label): + viewModel.removeShootLabel(label) + case .device(let device): + viewModel.removeCameraDevice(device) + case .schedule(let item): + Task { + await globalLoading.withLoading { + await viewModel.deleteSchedule(item: item, profileAPI: profileAPI, scheduleAPI: scheduleAPI, scenicId: scenicId) + } + } + } + } + + /// 更新账号快照里的展示资料。 + private func saveSnapshotProfile(_ profile: AccountProfile) { + let existing = snapshotStore.load() + snapshotStore.save( + AccountSnapshot( + profile: profile, + accountType: accountContext.accountType ?? existing?.accountType, + businessUserId: existing?.businessUserId, + currentRoleCode: existing?.currentRoleCode, + scenicScopes: accountContext.scenicScopes, + storeScopes: accountContext.storeScopes, + currentScenicId: accountContext.currentScenic?.id, + currentStoreId: accountContext.currentStore?.id + ) + ) + } + + private var defaultTime: Date { + Calendar.current.date(from: DateComponents(hour: 9, minute: 0)) ?? Date() + } + + private var nextSevenDays: [Date] { + (0..<7).compactMap { Calendar.current.date(byAdding: .day, value: $0, to: Date()) } + } + + private func isSelected(_ date: Date) -> Bool { + date.scheduleDayText == selectedDate.scheduleDayText + } + + private func weekdayText(for date: Date) -> String { + let symbols = ["日", "一", "二", "三", "四", "五", "六"] + let index = Calendar.current.component(.weekday, from: date) - 1 + return symbols[max(0, min(index, symbols.count - 1))] + } + + private func dayText(for date: Date) -> String { + "\(Calendar.current.component(.day, from: date))" + } + + private func maskedPhone(_ phone: String) -> String { + guard phone.count >= 11 else { return phone } + let prefix = phone.prefix(3) + let suffix = phone.suffix(4) + return "\(prefix)****\(suffix)" + } +} + +/// 个人空间页面删除确认目标。 +private enum ProfileSpaceDeleteTarget: Identifiable { + case attrLabel(String) + case shootLabel(String) + case device(String) + case schedule(ScheduleItem) + + var id: String { + switch self { + case .attrLabel(let label): "attr-\(label)" + case .shootLabel(let label): "shoot-\(label)" + case .device(let device): "device-\(device)" + case .schedule(let item): "schedule-\(item.id)" + } + } + + var title: String { + switch self { + case .attrLabel: "删除标签" + case .shootLabel: "删除拍摄说明" + case .device: "删除设备" + case .schedule: "删除日程" + } + } + + var message: String { + switch self { + case .attrLabel(let label): + "您确定要删除\(label)标签吗?" + case .shootLabel(let label): + "您确定要删除\(label)拍摄说明吗?" + case .device(let device): + "您确定要删除\(device)设备吗?" + case .schedule: + "您确定要删除该日程吗?" + } + } +} + +/// 简单流式布局,用于标签自动换行。 +private struct FlowLayout: Layout { + let spacing: CGFloat + let lineSpacing: CGFloat + + /// 计算流式布局所需尺寸。 + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? 0 + let rows = makeRows(maxWidth: maxWidth, subviews: subviews) + let width = rows.map(\.width).max() ?? 0 + let height = rows.reduce(CGFloat.zero) { $0 + $1.height } + lineSpacing * CGFloat(max(0, rows.count - 1)) + return CGSize(width: width, height: height) + } + + /// 摆放子视图。 + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let rows = makeRows(maxWidth: bounds.width, subviews: subviews) + var y = bounds.minY + for row in rows { + var x = bounds.minX + for item in row.items { + subviews[item.index].place( + at: CGPoint(x: x, y: y), + proposal: ProposedViewSize(width: item.size.width, height: item.size.height) + ) + x += item.size.width + spacing + } + y += row.height + lineSpacing + } + } + + /// 根据最大宽度拆分布局行。 + private func makeRows(maxWidth: CGFloat, subviews: Subviews) -> [FlowRow] { + guard maxWidth > 0 else { + return subviews.enumerated().map { index, subview in + let size = subview.sizeThatFits(.unspecified) + return FlowRow(items: [FlowItem(index: index, size: size)], width: size.width, height: size.height) + } + } + + var rows: [FlowRow] = [] + var currentItems: [FlowItem] = [] + var currentWidth: CGFloat = 0 + var currentHeight: CGFloat = 0 + + for (index, subview) in subviews.enumerated() { + let size = subview.sizeThatFits(.unspecified) + let nextWidth = currentItems.isEmpty ? size.width : currentWidth + spacing + size.width + if nextWidth > maxWidth, !currentItems.isEmpty { + rows.append(FlowRow(items: currentItems, width: currentWidth, height: currentHeight)) + currentItems = [FlowItem(index: index, size: size)] + currentWidth = size.width + currentHeight = size.height + } else { + currentItems.append(FlowItem(index: index, size: size)) + currentWidth = nextWidth + currentHeight = max(currentHeight, size.height) + } + } + + if !currentItems.isEmpty { + rows.append(FlowRow(items: currentItems, width: currentWidth, height: currentHeight)) + } + return rows + } + + private struct FlowItem { + let index: Int + let size: CGSize + } + + private struct FlowRow { + let items: [FlowItem] + let width: CGFloat + let height: CGFloat + } +} diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift new file mode 100644 index 0000000..35fe2f2 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -0,0 +1,130 @@ +// +// TravelAlbumAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +private let travelAlbumBase = "/api/yf-handset-app/photog/travel-album" + +/// 旅拍相册服务协议,便于 ViewModel 测试替换。 +@MainActor +protocol TravelAlbumServing { + func availableOrders() async throws -> [TravelAlbumAvailableOrder] + func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse + func albumList(page: Int, pageSize: Int) async throws -> ListPayload + func albumInfo(id: Int) async throws -> TravelAlbumItem + func editAlbum(_ request: TravelAlbumEditRequest) async throws + func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws + func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload + func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial + func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws + func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse +} + +/// 旅拍相册 API,封装 travel-album 相关接口。 +@MainActor +final class TravelAlbumAPI: TravelAlbumServing { + private let client: APIClient + + /// 初始化旅拍相册 API。 + init(client: APIClient) { + self.client = client + } + + /// 获取可绑定订单列表。 + func availableOrders() async throws -> [TravelAlbumAvailableOrder] { + try await client.send( + APIRequest(method: .get, path: "\(travelAlbumBase)/available-order") + ) + } + + /// 创建旅拍相册任务。 + func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse { + try await client.send( + APIRequest(method: .post, path: "\(travelAlbumBase)/create", body: request) + ) + } + + /// 获取旅拍相册列表。 + func albumList(page: Int, pageSize: Int) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "\(travelAlbumBase)/list", + queryItems: [ + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 获取旅拍相册详情。 + func albumInfo(id: Int) async throws -> TravelAlbumItem { + try await client.send( + APIRequest( + method: .get, + path: "\(travelAlbumBase)/info", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } + + /// 编辑旅拍相册。 + func editAlbum(_ request: TravelAlbumEditRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "\(travelAlbumBase)/edit", body: request) + ) as EmptyPayload + } + + /// 删除旅拍相册。 + func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "\(travelAlbumBase)/delete", body: request) + ) as EmptyPayload + } + + /// 获取相册素材列表。 + func materialList(userEquityTravelId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "\(travelAlbumBase)/material-list", + queryItems: [ + URLQueryItem(name: "user_equity_travel_id", value: "\(userEquityTravelId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"), + URLQueryItem(name: "order_by", value: "2") + ] + ) + ) + } + + /// 登记已上传 OSS 的素材。 + func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { + try await client.send( + APIRequest(method: .post, path: "\(travelAlbumBase)/upload-material", body: request) + ) + } + + /// 删除相册素材。 + func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "\(travelAlbumBase)/delete-material", body: request) + ) as EmptyPayload + } + + /// 获取小程序码。 + func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { + try await client.send( + APIRequest( + method: .get, + path: "\(travelAlbumBase)/mp-code", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } +} diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift new file mode 100644 index 0000000..2fa9ffe --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -0,0 +1,453 @@ +// +// TravelAlbumModels.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 旅拍相册用户摘要。 +struct TravelAlbumUser: Decodable, Equatable, Hashable { + let id: Int + let phone: String + + enum CodingKeys: String, CodingKey { + case id, phone + } + + init(id: Int = 0, phone: String = "") { + self.id = id + self.phone = phone + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.lossyInt(forKey: .id) + phone = container.lossyString(forKey: .phone) + } +} + +/// 旅拍相册任务实体。 +struct TravelAlbumItem: Decodable, Identifiable, Equatable, Hashable { + let id: Int + let storeUserId: Int + let name: String + /// 1 先拍后买,2 先买后拍 + let type: Int + let orderNumber: String + let materialNum: Int + let materialPrice: Int + let materialPackagePrice: Int + let photoPrice: Int + let coverURL: String + let userId: Int + let status: Int + let createdAt: String + let updatedAt: String + let user: TravelAlbumUser? + + enum CodingKeys: String, CodingKey { + case id + case storeUserId = "store_user_id" + case name, type + case orderNumber = "order_number" + case materialNum = "material_num" + case materialPrice = "material_price" + case materialPackagePrice = "material_package_price" + case photoPrice = "photo_price" + case coverURL = "cover_url" + case userId = "user_id" + case status + case createdAt = "created_at" + case updatedAt = "updated_at" + case user + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.lossyInt(forKey: .id) + storeUserId = container.lossyInt(forKey: .storeUserId) + name = container.lossyString(forKey: .name) + type = container.lossyInt(forKey: .type) + orderNumber = container.lossyString(forKey: .orderNumber) + materialNum = container.lossyInt(forKey: .materialNum) + materialPrice = container.lossyInt(forKey: .materialPrice) + materialPackagePrice = container.lossyInt(forKey: .materialPackagePrice) + photoPrice = container.lossyInt(forKey: .photoPrice) + coverURL = container.lossyString(forKey: .coverURL) + userId = container.lossyInt(forKey: .userId) + status = container.lossyInt(forKey: .status) + createdAt = container.lossyString(forKey: .createdAt) + updatedAt = container.lossyString(forKey: .updatedAt) + user = try? container.decodeIfPresent(TravelAlbumUser.self, forKey: .user) + } + + var displayPhone: String { + user?.phone ?? "" + } +} + +/// 可绑定订单实体(买了再拍)。 +struct TravelAlbumAvailableOrder: Decodable, Identifiable, Equatable, Hashable { + let projectName: String + let orderNumber: String + let userPhone: String + let userId: Int + + var id: String { orderNumber } + + enum CodingKeys: String, CodingKey { + case projectName = "project_name" + case orderNumber = "order_number" + case userPhone = "user_phone" + case userId = "user_id" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projectName = container.lossyString(forKey: .projectName) + orderNumber = container.lossyString(forKey: .orderNumber) + userPhone = container.lossyString(forKey: .userPhone) + userId = container.lossyInt(forKey: .userId) + } +} + +/// 旅拍相册素材实体。 +struct TravelAlbumMaterial: Decodable, Identifiable, Equatable { + let id: Int + let userEquityTravelId: Int + let status: Int + let orderNumber: String + let userId: Int + let fileName: String + let fileType: Int + let fileURL: String + let fileSize: Int + let coverURL: String + let isPurchased: Bool + let createdAt: String + let updatedAt: String + + enum CodingKeys: String, CodingKey { + case id + case userEquityTravelId = "user_equity_travel_id" + case status + case orderNumber = "order_number" + case userId = "user_id" + case fileName = "file_name" + case fileType = "file_type" + case fileURL = "file_url" + case fileSize = "file_size" + case coverURL = "cover_url" + case isPurchased = "is_purchased" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.lossyInt(forKey: .id) + userEquityTravelId = container.lossyInt(forKey: .userEquityTravelId) + status = container.lossyInt(forKey: .status) + orderNumber = container.lossyString(forKey: .orderNumber) + userId = container.lossyInt(forKey: .userId) + fileName = container.lossyString(forKey: .fileName) + fileType = container.lossyInt(forKey: .fileType) + fileURL = container.lossyString(forKey: .fileURL) + fileSize = container.lossyInt(forKey: .fileSize) + coverURL = container.lossyString(forKey: .coverURL) + isPurchased = container.lossyBool(forKey: .isPurchased) + createdAt = container.lossyString(forKey: .createdAt) + updatedAt = container.lossyString(forKey: .updatedAt) + } +} + +/// 创建旅拍相册请求。 +struct TravelAlbumCreateRequest: Encodable { + let name: String + let type: Int + let orderNumber: String? + let materialNum: Int? + let materialPrice: Double? + let materialPackagePrice: Double? + let photoPrice: Double? + + enum CodingKeys: String, CodingKey { + case name, type + case orderNumber = "order_number" + case materialNum = "material_num" + case materialPrice = "material_price" + case materialPackagePrice = "material_package_price" + case photoPrice = "photo_price" + } +} + +/// 创建旅拍相册响应。 +struct TravelAlbumCreateResponse: Decodable { + let id: Int + + init(id: Int) { + self.id = id + } + + enum CodingKeys: String, CodingKey { + case id + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.lossyInt(forKey: .id) + } +} + +/// 上传素材登记请求。 +struct TravelAlbumUploadMaterialRequest: Encodable { + let userEquityTravelId: Int + let fileName: String + let fileURL: String + + enum CodingKeys: String, CodingKey { + case userEquityTravelId = "user_equity_travel_id" + case fileName = "file_name" + case fileURL = "file_url" + } +} + +/// 删除旅拍相册请求。 +struct TravelAlbumDeleteRequest: Encodable { + let id: Int +} + +/// 编辑旅拍相册请求。 +struct TravelAlbumEditRequest: Encodable { + let id: Int + let name: String? + let materialPackagePrice: Double? + + enum CodingKeys: String, CodingKey { + case id, name + case materialPackagePrice = "material_package_price" + } +} + +/// 删除素材请求。 +struct TravelAlbumDeleteMaterialRequest: Encodable { + let id: Int +} + +/// 小程序码响应。 +struct TravelAlbumMpCodeResponse: Decodable { + let mpCodeOssURL: String + + init(mpCodeOssURL: String) { + self.mpCodeOssURL = mpCodeOssURL + } + + enum CodingKeys: String, CodingKey { + case mpCodeOssURL = "mp_code_oss_url" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + mpCodeOssURL = container.lossyString(forKey: .mpCodeOssURL) + } +} + +/// 有线传图页导航上下文。 +struct WiredTransferContext: Hashable { + let albumId: Int + let albumName: String + let phone: String + let orderNumber: String +} + +/// 有线传图上传状态。 +enum WiredTransferUploadStatus: String, Codable, Equatable { + case uploaded = "UPLOADED" + case pending = "PENDING" + case transferring = "TRANSFERRING" + case uploading = "UPLOADING" + case failed = "FAILED" + + var displayLabel: String { + switch self { + case .uploaded: "已上传" + case .pending: "待上传" + case .transferring: "传输中" + case .uploading: "上传中" + case .failed: "上传失败" + } + } +} + +/// 有线传图列表项。 +struct WiredTransferPhotoItem: Identifiable, Equatable { + let id: String + let sourceId: String + let fileName: String + let thumbnailURL: String + let capturedAt: String + let fileSizeText: String + let status: WiredTransferUploadStatus + let progress: Int + let errorMessage: String? + + init( + id: String, + sourceId: String? = nil, + fileName: String, + thumbnailURL: String, + capturedAt: String, + fileSizeText: String, + status: WiredTransferUploadStatus, + progress: Int = 0, + errorMessage: String? = nil + ) { + self.id = id + self.sourceId = sourceId ?? id + self.fileName = fileName + self.thumbnailURL = thumbnailURL + self.capturedAt = capturedAt + self.fileSizeText = fileSizeText + self.status = status + self.progress = progress + self.errorMessage = errorMessage + } + + var canSelectForSpecifyUpload: Bool { + status == .pending + } + + var isNotUploaded: Bool { + status == .pending || status == .failed + } +} + +/// 有线传图持久化记录。 +struct WiredTransferPhotoRecord: Codable, Equatable { + let id: String + let sourceId: String + let fileName: String + let localPath: String + let thumbnailPath: String + let capturedAt: String + let fileSizeBytes: Int64 + let status: String + let progress: Int + let errorMessage: String? + let albumId: Int + let userId: String + let remoteURL: String + let updatedAt: TimeInterval + + func toPhotoItem() -> WiredTransferPhotoItem { + let previewPath = [thumbnailPath, localPath, remoteURL] + .first { path in + !path.isEmpty && (path.hasPrefix("http") || FileManager.default.fileExists(atPath: path)) + } ?? "" + let thumbnailURL: String + if previewPath.hasPrefix("http") { + thumbnailURL = previewPath + } else if !previewPath.isEmpty { + thumbnailURL = "file://\(previewPath)" + } else { + thumbnailURL = "" + } + return WiredTransferPhotoItem( + id: id, + sourceId: sourceId, + fileName: fileName, + thumbnailURL: thumbnailURL, + capturedAt: capturedAt, + fileSizeText: Self.formatFileSize(fileSizeBytes), + status: WiredTransferUploadStatus(rawValue: status) ?? .pending, + progress: progress, + errorMessage: errorMessage + ) + } + + static func formatFileSize(_ bytes: Int64) -> String { + guard bytes > 0 else { return "--" } + let mb = Double(bytes) / 1024.0 / 1024.0 + if mb >= 1024 { + return String(format: "%.2f GB", mb / 1024.0) + } + return String(format: "%.2f MB", mb) + } +} + +extension WiredTransferPhotoItem { + /// 转为持久化记录。 + func toRecord( + albumId: Int, + userId: String, + localPath: String, + thumbnailPath: String = "", + fileSizeBytes: Int64, + remoteURL: String = "" + ) -> WiredTransferPhotoRecord { + WiredTransferPhotoRecord( + id: id, + sourceId: sourceId, + fileName: fileName, + localPath: localPath, + thumbnailPath: thumbnailPath, + capturedAt: capturedAt, + fileSizeBytes: fileSizeBytes, + status: status.rawValue, + progress: progress, + errorMessage: errorMessage, + albumId: albumId, + userId: userId, + remoteURL: remoteURL, + updatedAt: Date().timeIntervalSince1970 + ) + } +} + +extension Array where Element == WiredTransferPhotoItem { + /// 按 Tab 过滤:0 全部,1 已上传,2 失败。 + func filtered(byTabIndex index: Int) -> [WiredTransferPhotoItem] { + switch index { + case 1: filter { $0.status == .uploaded } + case 2: filter { $0.status == .failed } + default: self + } + } + + /// 返回各 Tab 计数。 + var transferTabCounts: [Int] { + [count, filter { $0.status == .uploaded }.count, filter { $0.status == .failed }.count] + } + + /// 可指定上传的 photo id 集合。 + var selectablePendingPhotoIDs: Set { + Set(filter { $0.canSelectForSpecifyUpload }.map(\.id)) + } +} + +private extension KeyedDecodingContainer { + func lossyString(forKey key: Key) -> String { + if let value = try? decode(String.self, forKey: key) { return value } + if let value = try? decode(Int.self, forKey: key) { return "\(value)" } + if let value = try? decode(Double.self, forKey: key) { return "\(value)" } + return "" + } + + func lossyInt(forKey key: Key) -> Int { + if let value = try? decode(Int.self, forKey: key) { return value } + if let value = try? decode(String.self, forKey: key), let intValue = Int(value) { return intValue } + if let value = try? decode(Double.self, forKey: key) { return Int(value) } + return 0 + } + + func lossyBool(forKey key: Key) -> Bool { + if let value = try? decode(Bool.self, forKey: key) { return value } + if let value = try? decode(Int.self, forKey: key) { return value != 0 } + if let value = try? decode(String.self, forKey: key) { + return value == "1" || value.lowercased() == "true" + } + return false + } +} diff --git a/suixinkan/Features/TravelAlbum/Services/TravelAlbumMaterialUploader.swift b/suixinkan/Features/TravelAlbum/Services/TravelAlbumMaterialUploader.swift new file mode 100644 index 0000000..8971a15 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/TravelAlbumMaterialUploader.swift @@ -0,0 +1,63 @@ +// +// TravelAlbumMaterialUploader.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 旅拍相册素材上传器,实现 OSS 上传 + 后端 upload-material 登记。 +@MainActor +final class TravelAlbumMaterialUploader: CameraAssetUploadSink { + private let api: any TravelAlbumServing + private let ossService: any OSSUploadServing + private let albumID: Int + private let scenicID: Int + + /// 初始化上传器,注入 API、OSS 与业务上下文。 + init(api: any TravelAlbumServing, ossService: any OSSUploadServing, albumID: Int, scenicID: Int) { + self.api = api + self.ossService = ossService + self.albumID = albumID + self.scenicID = scenicID + } + + /// 上传本地文件到 OSS 并登记到旅拍相册。 + func upload( + localURL: URL, + fileName: String, + fileType: Int, + progress: @escaping @Sendable (Int) -> Void + ) async throws -> String { + guard albumID > 0 else { + throw APIError.serverCode(0, "请先选择有效的相册") + } + guard FileManager.default.fileExists(atPath: localURL.path) else { + throw APIError.serverCode(0, "相机文件读取失败") + } + + let data = try Data(contentsOf: localURL) + guard !data.isEmpty else { + throw APIError.serverCode(0, "相机文件读取失败") + } + + let remoteURL = try await ossService.uploadAlbumFile( + data: data, + fileName: fileName, + fileType: fileType, + scenicId: scenicID, + onProgress: progress + ) + + _ = try await api.uploadMaterial( + TravelAlbumUploadMaterialRequest( + userEquityTravelId: albumID, + fileName: fileName, + fileURL: remoteURL + ) + ) + + return remoteURL + } +} diff --git a/suixinkan/Features/TravelAlbum/Services/TravelAlbumSession.swift b/suixinkan/Features/TravelAlbum/Services/TravelAlbumSession.swift new file mode 100644 index 0000000..a76a0ef --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/TravelAlbumSession.swift @@ -0,0 +1,13 @@ +// +// TravelAlbumSession.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 旅拍相册跨页会话上下文,保存当前操作的相册任务。 +enum TravelAlbumSession { + static var currentAlbum: TravelAlbumItem? +} diff --git a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift new file mode 100644 index 0000000..fbe5e41 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift @@ -0,0 +1,219 @@ +// +// WiredTransferPhotoStore.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 有线传图本地记录仓库,按相册与用户维度持久化到 UserDefaults。 +final class WiredTransferPhotoStore { + private let defaults: UserDefaults + private let accountPrefixProvider: () -> String + private let userIDProvider: () -> String + + private static let keyPrefix = "wired_transfer_photos_" + private static let deletedSuffix = "_deleted" + private static let bindingsSuffix = "_photo_bindings" + + /// 初始化仓库,注入账号前缀与用户 ID 提供者。 + init( + defaults: UserDefaults = .standard, + accountPrefixProvider: @escaping () -> String, + userIDProvider: @escaping () -> String + ) { + self.defaults = defaults + self.accountPrefixProvider = accountPrefixProvider + self.userIDProvider = userIDProvider + } + + /// 查询照片绑定的相册 ID。 + func albumID(forPhoto photoID: String) -> Int? { + guard !photoID.isEmpty else { return nil } + return loadBindings().first { $0.photoID == photoID }?.albumID + } + + /// 绑定照片到相册。 + func bindPhotoToAlbum(photoID: String, albumID: Int) { + guard !photoID.isEmpty, albumID > 0, !userIDProvider().isEmpty else { return } + var bindings = loadBindings().filter { $0.photoID != photoID } + bindings.append(PhotoAlbumBinding(photoID: photoID, albumID: albumID)) + saveBindings(bindings) + } + + /// 加载已删除照片 ID。 + func loadDeletedIDs(albumID: Int) -> Set { + guard albumID > 0, !userIDProvider().isEmpty else { return [] } + let ids = defaults.stringArray(forKey: deletedKey(albumID)) ?? [] + return Set(ids) + } + + /// 加载相册下的传图记录。 + func load(albumID: Int) -> [WiredTransferPhotoRecord] { + guard albumID > 0 else { return [] } + let userID = userIDProvider() + guard !userID.isEmpty else { return [] } + + let deletedIDs = loadDeletedIDs(albumID: albumID) + guard let data = defaults.data(forKey: storageKey(albumID)), + let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data) + else { return [] } + + return records + .filter { record in + !deletedIDs.contains(record.id) + && record.userId == userID + && record.albumId == albumID + && record.isDisplayable + } + .map { $0.normalizeInterruptedTransfer() } + .sorted { $0.capturedAt > $1.capturedAt } + .also { items in + items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) } + } + } + + /// 保存相册传图记录。 + func save(albumID: Int, records: [WiredTransferPhotoRecord]) { + guard albumID > 0 else { return } + let userID = userIDProvider() + guard !userID.isEmpty else { return } + + let normalized = records + .filter { $0.userId == userID && $0.albumId == albumID } + .sorted { $0.capturedAt > $1.capturedAt } + normalized.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) } + + if let data = try? JSONEncoder().encode(normalized) { + defaults.set(data, forKey: storageKey(albumID)) + } + } + + /// 标记并移除单张照片。 + func remove(albumID: Int, photoID: String) { + guard albumID > 0, !photoID.isEmpty else { return } + markDeleted(albumID: albumID, photoID: photoID) + let deletedIDs = loadDeletedIDs(albumID: albumID) + guard let data = defaults.data(forKey: storageKey(albumID)), + var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data) + else { return } + records = records.filter { record in + !deletedIDs.contains(record.id) + && record.userId == userIDProvider() + && record.albumId == albumID + } + save(albumID: albumID, records: records) + } + + /// 标记照片已删除。 + func markDeleted(albumID: Int, photoID: String) { + guard albumID > 0, !photoID.isEmpty, !userIDProvider().isEmpty else { return } + let updated = loadDeletedIDs(albumID: albumID).union([photoID]) + defaults.set(Array(updated), forKey: deletedKey(albumID)) + } + + /// 清空相册关联的本地缓存。 + func clearAlbum(albumID: Int) -> Set { + guard albumID > 0 else { return [] } + let userID = userIDProvider() + guard !userID.isEmpty else { return [] } + + guard let data = defaults.data(forKey: storageKey(albumID)), + let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data) + else { return [] } + + let userRecords = records.filter { $0.userId == userID } + userRecords.forEach { deleteRecordFiles($0) } + + var bindings = loadBindings().filter { $0.albumID != albumID } + saveBindings(bindings) + + defaults.removeObject(forKey: storageKey(albumID)) + defaults.removeObject(forKey: deletedKey(albumID)) + + return Set(userRecords.map(\.id)) + } + + private func deleteRecordFiles(_ record: WiredTransferPhotoRecord) { + deleteFileIfExists(record.localPath) + deleteFileIfExists(record.thumbnailPath) + } + + private func deleteFileIfExists(_ path: String) { + guard !path.isEmpty else { return } + let url = URL(fileURLWithPath: path) + if FileManager.default.fileExists(atPath: url.path) { + try? FileManager.default.removeItem(at: url) + } + } + + private func storageKey(_ albumID: Int) -> String { + "\(accountPrefixProvider())\(Self.keyPrefix)\(albumID)" + } + + private func deletedKey(_ albumID: Int) -> String { + "\(storageKey(albumID))\(Self.deletedSuffix)" + } + + private func bindingsKey() -> String { + "\(accountPrefixProvider())\(Self.bindingsSuffix)" + } + + private struct PhotoAlbumBinding: Codable { + let photoID: String + let albumID: Int + } + + private func loadBindings() -> [PhotoAlbumBinding] { + guard let data = defaults.data(forKey: bindingsKey()), + let bindings = try? JSONDecoder().decode([PhotoAlbumBinding].self, from: data) + else { return [] } + return bindings + } + + private func saveBindings(_ bindings: [PhotoAlbumBinding]) { + if let data = try? JSONEncoder().encode(bindings) { + defaults.set(data, forKey: bindingsKey()) + } + } +} + +private extension WiredTransferPhotoRecord { + var isDisplayable: Bool { + !localPath.isEmpty || !remoteURL.isEmpty + } + + func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord { + guard status == WiredTransferUploadStatus.transferring.rawValue + || status == WiredTransferUploadStatus.uploading.rawValue + else { return self } + + var copy = self + copy = WiredTransferPhotoRecord( + id: id, + sourceId: sourceId, + fileName: fileName, + localPath: localPath, + thumbnailPath: thumbnailPath, + capturedAt: capturedAt, + fileSizeBytes: fileSizeBytes, + status: remoteURL.isEmpty ? WiredTransferUploadStatus.pending.rawValue : WiredTransferUploadStatus.uploaded.rawValue, + progress: remoteURL.isEmpty ? 0 : 100, + errorMessage: errorMessage, + albumId: albumId, + userId: userId, + remoteURL: remoteURL, + updatedAt: updatedAt + ) + return copy + } +} + +private extension Array { + @discardableResult + func also(_ body: (Self) -> Void) -> Self { + body(self) + return self + } +} diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md new file mode 100644 index 0000000..4ec8dfb --- /dev/null +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -0,0 +1,38 @@ +# TravelAlbum + +## 职责 + +旅拍相册(新增相册 / 新建相册任务)模块,包含任务创建、相册详情、小程序码与 USB 有线传图上传。 + +## 入口 + +- 权限 URI:`travel_album` → `TravelAlbumEntryView` +- Android 对照:`ui/travelalbum` + +## 核心页面 + +| 页面 | 说明 | +| --- | --- | +| `TravelAlbumEntryView` | 新增相册列表、新建任务入口 | +| `CreateTravelAlbumSheet` | 先拍再买 / 买了再拍创建弹窗 | +| `TravelAlbumDetailView` | 相册详情与素材管理 | +| `WiredCameraTransferView` | 有线传图页 | + +## 业务流程 + +1. 创建任务:`POST .../travel-album/create` +2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`) +3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile` +4. 素材登记:`POST .../travel-album/upload-material` + +## 解耦关系 + +- `CameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议 +- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记 +- 与「相册管理」(`Features/Assets`)API、Store、路由完全隔离 + +## 依赖模块 + +- `Core/CameraTethering` — USB/PTP 相机 +- `Core/CameraTransfer` — 传输编排 +- `Core/Upload` — OSS STS 上传 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift new file mode 100644 index 0000000..1615a55 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -0,0 +1,437 @@ +// +// TravelAlbumViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation +import Combine + +/// 旅拍相册入口 ViewModel,负责列表、创建任务与小程序码。 +@MainActor +final class TravelAlbumEntryViewModel: ObservableObject { + static let modePreShoot = 1 + static let modePreOrder = 2 + static let apiTypePreShoot = 1 + static let apiTypePreOrder = 2 + + @Published var albums: [TravelAlbumItem] = [] + @Published var total = 0 + @Published var isLoading = false + @Published var isCreating = false + @Published var showCreateSheet = false + @Published var bindableOrders: [TravelAlbumAvailableOrder] = [] + @Published var codeSheet: TravelAlbumCodeSheetState? + @Published var errorMessage: String? + + /// 加载相册列表。 + func loadAlbums(api: any TravelAlbumServing) async { + isLoading = true + defer { isLoading = false } + do { + let payload = try await api.albumList(page: 1, pageSize: 20) + albums = payload.list + total = payload.total + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + /// 打开创建弹窗并加载可绑定订单。 + func openCreateSheet(api: any TravelAlbumServing, scenicSelected: Bool) async { + guard !isCreating else { return } + guard scenicSelected else { + errorMessage = "请先选择景区" + return + } + showCreateSheet = true + await loadBindableOrders(api: api) + } + + /// 加载可绑定订单。 + func loadBindableOrders(api: any TravelAlbumServing) async { + do { + bindableOrders = try await api.availableOrders() + } catch { + errorMessage = error.localizedDescription + } + } + + /// 确认创建旅拍相册任务。 + func confirmCreateTask( + api: any TravelAlbumServing, + mode: Int, + freeCount: String, + singlePrice: String, + packagePrice: String, + order: TravelAlbumAvailableOrder?, + scenicSelected: Bool + ) async -> Bool { + guard !isCreating else { return false } + guard scenicSelected else { + errorMessage = "请先选择景区" + return false + } + + let apiType = mode == Self.modePreOrder ? Self.apiTypePreOrder : Self.apiTypePreShoot + let albumName: String + if mode == Self.modePreOrder { + albumName = order?.projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? order!.projectName + : Self.buildTodayAlbumName(existingNames: albums.map(\.name)) + } else { + albumName = Self.buildTodayAlbumName(existingNames: albums.map(\.name)) + } + + if mode == Self.modePreOrder, order == nil { + errorMessage = "请选择绑定订单" + return false + } + if mode == Self.modePreShoot, singlePrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + errorMessage = "请输入单张照片价格" + return false + } + + let materialPrice = Double(singlePrice.trimmingCharacters(in: .whitespacesAndNewlines)) + if mode == Self.modePreShoot, materialPrice == nil { + errorMessage = "请输入有效的单张照片价格" + return false + } + + let request: TravelAlbumCreateRequest + if apiType == Self.apiTypePreShoot { + request = TravelAlbumCreateRequest( + name: albumName, + type: apiType, + orderNumber: nil, + materialNum: Int(freeCount) ?? 0, + materialPrice: materialPrice, + materialPackagePrice: Double(packagePrice) ?? 0, + photoPrice: 0 + ) + } else { + request = TravelAlbumCreateRequest( + name: albumName, + type: apiType, + orderNumber: order?.orderNumber, + materialNum: nil, + materialPrice: nil, + materialPackagePrice: nil, + photoPrice: nil + ) + } + + isCreating = true + defer { isCreating = false } + + do { + _ = try await api.createAlbum(request) + showCreateSheet = false + await loadAlbums(api: api) + errorMessage = nil + return true + } catch { + let message = error.localizedDescription + if message.contains("订单已关联") { + await loadBindableOrders(api: api) + } + errorMessage = message.isEmpty ? "创建失败" : message + return false + } + } + + /// 打开小程序码弹窗。 + func openAlbumCode(api: any TravelAlbumServing, album: TravelAlbumItem) async { + codeSheet = TravelAlbumCodeSheetState(albumID: album.id, albumName: album.name, isLoading: true, qrImageURL: "") + do { + let response = try await api.mpCode(id: album.id) + codeSheet?.qrImageURL = response.mpCodeOssURL.trimmingCharacters(in: .whitespacesAndNewlines) + codeSheet?.isLoading = false + } catch { + codeSheet?.isLoading = false + errorMessage = error.localizedDescription + } + } + + /// 生成当日相册名称后缀。 + static func buildTodayAlbumName(existingNames: [String]) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let prefix = formatter.string(from: Date()) + let todayNames = existingNames.filter { $0.hasPrefix(prefix) } + let nextIndex = todayNames.count + 1 + return String(format: "%@-%03d", prefix, nextIndex) + } +} + +/// 小程序码弹窗状态。 +struct TravelAlbumCodeSheetState: Identifiable { + let albumID: Int + let albumName: String + var isLoading: Bool + var qrImageURL: String + + var id: Int { albumID } +} + +/// 旅拍相册详情 ViewModel。 +@MainActor +final class TravelAlbumDetailViewModel: ObservableObject { + @Published var album: TravelAlbumItem? + @Published var materials: [TravelAlbumMaterial] = [] + @Published var isLoading = false + @Published var isDeleting = false + @Published var errorMessage: String? + + /// 加载相册详情与素材。 + func load(api: any TravelAlbumServing, albumID: Int) async { + isLoading = true + defer { isLoading = false } + do { + async let info = api.albumInfo(id: albumID) + async let list = api.materialList(userEquityTravelId: albumID, page: 1, pageSize: 100) + album = try await info + materials = try await list.list + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + /// 删除素材。 + func deleteMaterial(api: any TravelAlbumServing, materialID: Int, albumID: Int) async { + do { + try await api.deleteMaterial(TravelAlbumDeleteMaterialRequest(id: materialID)) + await load(api: api, albumID: albumID) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 删除相册。 + func deleteAlbum(api: any TravelAlbumServing, albumID: Int, photoStore: WiredTransferPhotoStore) async -> Bool { + isDeleting = true + defer { isDeleting = false } + do { + try await api.deleteAlbum(TravelAlbumDeleteRequest(id: albumID)) + _ = photoStore.clearAlbum(albumID: albumID) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} + +/// 有线传图 ViewModel,编排相机连接、传输管道与本地持久化。 +@MainActor +final class WiredCameraTransferViewModel: ObservableObject { + static let modeLiveCapture = "边拍边传" + static let modePostShootTransfer = "拍完再传" + + @Published var photos: [WiredTransferPhotoItem] = [] + @Published var selectedTabIndex = 0 + @Published var sidebarExpanded = true + @Published var selectedTimeSlotID: String? + @Published var selectUploadMode = false + @Published var selectedPhotoIDs: Set = [] + @Published var transferModeOption = modeLiveCapture + @Published var showSpecifyUploadSheet = false + @Published var connectionState: CameraConnectionState = .disconnected + @Published var errorMessage: String? + + let context: WiredTransferContext + private let pipeline: CameraTransferPipeline + private var photoStore: WiredTransferPhotoStore? + private var userIDProvider: (() -> String)? + private var sessionNewPhotoIDs: Set = [] + + /// 初始化有线传图 ViewModel。 + init( + context: WiredTransferContext, + cameraService: any CameraServiceProtocol + ) { + self.context = context + self.pipeline = CameraTransferPipeline(cameraService: cameraService) + + pipeline.onConnectionStateChange = { [weak self] state in + Task { @MainActor in + self?.connectionState = state + } + } + pipeline.onTasksUpdated = { [weak self] tasks in + Task { @MainActor in + self?.applyPipelineTasks(tasks) + } + } + } + + /// 配置上传 Sink 并启动相机连接。 + func start( + api: any TravelAlbumServing, + ossService: any OSSUploadServing, + scenicID: Int, + accountContext: AccountContext + ) async { + if photoStore == nil { + photoStore = WiredTransferPhotoStore( + accountPrefixProvider: { accountContext.accountCachePrefix ?? "guest_" }, + userIDProvider: { accountContext.profile?.userId ?? "" } + ) + userIDProvider = { accountContext.profile?.userId ?? "" } + } + let uploader = TravelAlbumMaterialUploader( + api: api, + ossService: ossService, + albumID: context.albumId, + scenicID: scenicID + ) + let autoUpload = transferModeOption == Self.modeLiveCapture + pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload) + loadPersistedPhotos() + await pipeline.connect() + } + + /// 断开相机连接。 + func stop() async { + await pipeline.disconnect() + } + + /// 切换传输模式。 + func selectTransferMode(_ option: String, api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) { + guard option == Self.modeLiveCapture || option == Self.modePostShootTransfer else { return } + transferModeOption = option + let autoUpload = option == Self.modeLiveCapture + let uploader = TravelAlbumMaterialUploader( + api: api, + ossService: ossService, + albumID: context.albumId, + scenicID: scenicID + ) + pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload) + } + + /// 批量上传所有待上传照片。 + func batchUploadAll() async { + let pendingIDs = photos.filter { $0.isNotUploaded }.map(\.id) + await pipeline.uploadAssets(withIDs: pendingIDs) + } + + /// 上传选中照片。 + func uploadSelected() async { + await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs)) + selectUploadMode = false + selectedPhotoIDs.removeAll() + } + + /// 重试单张照片上传。 + func retryPhoto(id: String) async { + await pipeline.uploadAssets(withIDs: [id]) + } + + /// 删除本地照片记录。 + func deletePhoto(id: String) { + photoStore?.remove(albumID: context.albumId, photoID: id) + photos.removeAll { $0.id == id } + } + + /// 切换照片选中状态。 + func togglePhotoSelection(id: String) { + if selectedPhotoIDs.contains(id) { + selectedPhotoIDs.remove(id) + } else { + selectedPhotoIDs.insert(id) + } + } + + /// 同步相机历史照片。 + func syncExistingPhotos() async { + await pipeline.syncExistingPhotos() + } + + /// 重试失败上传。 + func retryFailedUploads() async { + await pipeline.retryFailedUploads() + } + + private func loadPersistedPhotos() { + guard context.albumId > 0, let photoStore else { return } + let records = photoStore.load(albumID: context.albumId) + photos = records.map { $0.toPhotoItem() } + } + + 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 } + } + + for task in tasks { + guard belongsToCurrentAlbum(task.assetID) else { continue } + + let status: WiredTransferUploadStatus + switch task.status { + case .pending, .downloading: + status = .transferring + case .downloaded: + status = .pending + case .uploading: + status = .uploading + case .uploaded: + status = .uploaded + case .failed: + status = .failed + } + + let capturedAt = Self.captureTimestampFormatter.string(from: Date()) + let localPath = task.localPath ?? "" + let fileSizeBytes = (try? FileManager.default.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0 + let thumbnailURL = localPath.isEmpty ? "" : "file://\(localPath)" + + let item = WiredTransferPhotoItem( + id: task.assetID, + fileName: task.filename, + thumbnailURL: thumbnailURL, + capturedAt: capturedAt, + fileSizeText: WiredTransferPhotoRecord.formatFileSize(fileSizeBytes), + status: status, + progress: task.progress, + errorMessage: task.errorMessage + ) + merged.insert(item, at: 0) + sessionNewPhotoIDs.insert(task.assetID) + + let record = item.toRecord( + albumId: context.albumId, + userId: userID, + localPath: localPath, + fileSizeBytes: fileSizeBytes, + remoteURL: task.remoteURL ?? "" + ) + var records = photoStore.load(albumID: context.albumId) + records.removeAll { $0.id == record.id } + records.insert(record, at: 0) + photoStore.save(albumID: context.albumId, records: records) + } + + photos = merged.sorted { $0.capturedAt > $1.capturedAt } + } + + private func belongsToCurrentAlbum(_ photoID: String) -> Bool { + if let bound = photoStore?.albumID(forPhoto: photoID) { + return bound == context.albumId + } + return sessionNewPhotoIDs.contains(photoID) + } + + private static let captureTimestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() +} diff --git a/suixinkan/Features/TravelAlbum/Views/CreateTravelAlbumSheet.swift b/suixinkan/Features/TravelAlbum/Views/CreateTravelAlbumSheet.swift new file mode 100644 index 0000000..c132983 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/CreateTravelAlbumSheet.swift @@ -0,0 +1,158 @@ +// +// CreateTravelAlbumSheet.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI + +/// 新建相册任务弹窗,支持先拍再买与买了再拍两种模式。 +struct CreateTravelAlbumSheet: View { + let orders: [TravelAlbumAvailableOrder] + let isCreating: Bool + let onConfirm: (Int, String, String, String, TravelAlbumAvailableOrder?) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var mode = TravelAlbumEntryViewModel.modePreShoot + @State private var freeCount = "" + @State private var singlePrice = "" + @State private var packagePrice = "" + @State private var selectedOrder: TravelAlbumAvailableOrder? + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + modeCard( + title: "先拍再买", + description: "拍完后分享给用户,用户在小程序上选择性购买", + selected: mode == TravelAlbumEntryViewModel.modePreShoot + ) { + mode = TravelAlbumEntryViewModel.modePreShoot + } + + modeCard( + title: "买了再拍", + description: "用户已在小程序下单,绑定订单后用户可直接选片", + selected: mode == TravelAlbumEntryViewModel.modePreOrder + ) { + mode = TravelAlbumEntryViewModel.modePreOrder + } + + if mode == TravelAlbumEntryViewModel.modePreShoot { + preShootFields + } else { + preOrderFields + } + } + .padding(AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("新建相册任务") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isCreating ? "创建中" : "确认创建") { + onConfirm(mode, freeCount, singlePrice, packagePrice, selectedOrder) + } + .disabled(isCreating) + } + } + } + } + + private var preShootFields: some View { + VStack(spacing: AppMetrics.Spacing.small) { + fieldRow(title: "免费张数", placeholder: "请输入免费张数", text: $freeCount, keyboard: .numberPad) + fieldRow(title: "单张价格(元)", placeholder: "请输入单张照片价格", text: $singlePrice, keyboard: .decimalPad) + fieldRow(title: "打包价(元)", placeholder: "可选", text: $packagePrice, keyboard: .decimalPad) + } + } + + private var preOrderFields: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("选择绑定订单") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(AppDesign.textSecondary) + + if orders.isEmpty { + Text("暂无可绑定订单") + .foregroundStyle(AppDesign.placeholder) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } else { + ForEach(orders) { order in + Button { + selectedOrder = order + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(order.projectName) + .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + Text("\(order.orderNumber) · \(order.userPhone)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + Image(systemName: selectedOrder?.orderNumber == order.orderNumber ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedOrder?.orderNumber == order.orderNumber ? AppDesign.primary : AppDesign.placeholder) + } + .padding(AppMetrics.Spacing.medium) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + .buttonStyle(.plain) + } + } + } + } + + private func modeCard(title: String, description: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) { + Image(systemName: selected ? "largecircle.fill.circle" : "circle") + .foregroundStyle(selected ? AppDesign.primary : AppDesign.placeholder) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(description) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + .multilineTextAlignment(.leading) + } + Spacer() + } + .padding(AppMetrics.Spacing.large) + .background(Color.white) + .overlay( + RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card) + .stroke(selected ? AppDesign.primary : Color.clear, lineWidth: 1.5) + ) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + .buttonStyle(.plain) + } + + private func fieldRow(title: String, placeholder: String, text: Binding, keyboard: UIKeyboardType) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + TextField(placeholder, text: text) + .keyboardType(keyboard) + .padding(AppMetrics.Spacing.medium) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumCodeSheet.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumCodeSheet.swift new file mode 100644 index 0000000..41276c6 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumCodeSheet.swift @@ -0,0 +1,46 @@ +// +// TravelAlbumCodeSheet.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI + +/// 旅拍相册小程序码弹窗。 +struct TravelAlbumCodeSheet: View { + let state: TravelAlbumCodeSheetState + + var body: some View { + NavigationStack { + VStack(spacing: AppMetrics.Spacing.large) { + Text(state.albumName) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + + if state.isLoading { + ProgressView("加载小程序码…") + .frame(height: 220) + } else if state.qrImageURL.isEmpty { + AppContentUnavailableView("暂无小程序码", systemImage: "qrcode") + .frame(height: 220) + } else { + RemoteImage(urlString: state.qrImageURL) { + Image(systemName: "qrcode") + .font(.system(size: 48)) + .foregroundStyle(AppDesign.placeholder) + } + .frame(width: 220, height: 220) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + Text("用户可扫码进入小程序选片") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } + .padding(AppMetrics.Spacing.pageHorizontal) + .navigationTitle("相册码") + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium]) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift new file mode 100644 index 0000000..814fec7 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift @@ -0,0 +1,156 @@ +// +// TravelAlbumDetailView.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI + +/// 旅拍相册详情页,展示素材网格与管理操作。 +struct TravelAlbumDetailView: View { + let albumID: Int + + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.travelAlbumAPI) private var travelAlbumAPI + @EnvironmentObject private var toastCenter: ToastCenter + @Environment(\.globalLoading) private var globalLoading + @Environment(\.dismiss) private var dismiss + + @StateObject private var viewModel = TravelAlbumDetailViewModel() + @State private var showDeleteConfirm = false + + private var photoStore: WiredTransferPhotoStore { + WiredTransferPhotoStore( + accountPrefixProvider: { accountContext.accountCachePrefix ?? "guest_" }, + userIDProvider: { accountContext.profile?.userId ?? "" } + ) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + if let album = viewModel.album { + albumHeader(album) + } + + if viewModel.materials.isEmpty, !viewModel.isLoading { + AppContentUnavailableView("暂无素材", systemImage: "photo.on.rectangle") + .frame(maxWidth: .infinity, minHeight: 200) + } else { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(viewModel.materials) { material in + materialCell(material) + } + } + } + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle(viewModel.album?.name ?? "相册详情") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if let album = viewModel.album { + NavigationLink { + WiredCameraTransferView(context: WiredTransferContext( + albumId: album.id, + albumName: album.name, + phone: album.displayPhone, + orderNumber: album.orderNumber + )) + } label: { + Image(systemName: "cable.connector") + } + .accessibilityLabel("有线传图") + } + + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("删除相册") + } + } + .confirmationDialog("确认删除该相册?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + Button("删除", role: .destructive) { + Task { await deleteAlbum() } + } + Button("取消", role: .cancel) {} + } + .task(id: albumID) { + globalLoading.show() + defer { globalLoading.hide() } + await viewModel.load(api: travelAlbumAPI, albumID: albumID) + } + .refreshable { + await viewModel.load(api: travelAlbumAPI, albumID: albumID) + } + .onChange(of: viewModel.errorMessage) { message in + guard let message, !message.isEmpty else { return } + toastCenter.show(message) + } + } + + private func albumHeader(_ album: TravelAlbumItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(album.type == 1 ? "先拍再买" : "买了再拍") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.primary) + if !album.displayPhone.isEmpty { + Label(album.displayPhone, systemImage: "phone.fill") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } + if !album.orderNumber.isEmpty { + Text("订单号:\(album.orderNumber)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(AppMetrics.Spacing.large) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + private func materialCell(_ material: TravelAlbumMaterial) -> some View { + ZStack(alignment: .topTrailing) { + RemoteImage(urlString: material.coverURL.isEmpty ? material.fileURL : material.coverURL) { + Color(hex: 0xE5E7EB) + } + .aspectRatio(1, contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Menu { + Button(role: .destructive) { + Task { + await viewModel.deleteMaterial(api: travelAlbumAPI, materialID: material.id, albumID: albumID) + } + } label: { + Label("删除", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .padding(6) + .background(Color.black.opacity(0.35)) + .foregroundStyle(.white) + .clipShape(Circle()) + .padding(4) + } + } + } + + private func deleteAlbum() async { + globalLoading.show() + defer { globalLoading.hide() } + let success = await viewModel.deleteAlbum(api: travelAlbumAPI, albumID: albumID, photoStore: photoStore) + if success { + toastCenter.show("相册已删除") + dismiss() + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift b/suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift new file mode 100644 index 0000000..c252ca6 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift @@ -0,0 +1,207 @@ +// +// TravelAlbumEntryView.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI + +/// 旅拍相册入口页,展示任务列表与新建入口。 +struct TravelAlbumEntryView: View { + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.travelAlbumAPI) private var travelAlbumAPI + @EnvironmentObject private var toastCenter: ToastCenter + @Environment(\.globalLoading) private var globalLoading + + @StateObject private var viewModel = TravelAlbumEntryViewModel() + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + createHeroCard + if viewModel.isLoading, viewModel.albums.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 200) + } else if viewModel.albums.isEmpty { + AppContentUnavailableView("暂无相册任务", systemImage: "photo.on.rectangle.angled", description: Text("点击“新建相册任务”开始创建任务")) + .frame(maxWidth: .infinity, minHeight: 240) + } else { + LazyVStack(spacing: AppMetrics.Spacing.medium) { + ForEach(viewModel.albums) { album in + TravelAlbumCard( + album: album, + onOpenDetail: { TravelAlbumSession.currentAlbum = album }, + onOpenWiredTransfer: { TravelAlbumSession.currentAlbum = album }, + onOpenCode: { + Task { + await viewModel.openAlbumCode(api: travelAlbumAPI, album: album) + } + } + ) + } + } + } + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("新增相册") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await reload(showLoading: false) + } + .task { + await reload(showLoading: viewModel.albums.isEmpty) + } + .sheet(isPresented: $viewModel.showCreateSheet) { + CreateTravelAlbumSheet( + orders: viewModel.bindableOrders, + isCreating: viewModel.isCreating, + onConfirm: { mode, freeCount, singlePrice, packagePrice, order in + Task { + let success = await viewModel.confirmCreateTask( + api: travelAlbumAPI, + mode: mode, + freeCount: freeCount, + singlePrice: singlePrice, + packagePrice: packagePrice, + order: order, + scenicSelected: accountContext.currentScenic != nil + ) + if success { + toastCenter.show("任务创建成功") + } else if let message = viewModel.errorMessage { + toastCenter.show(message) + } + } + } + ) + .presentationDetents([.large]) + } + .sheet(item: $viewModel.codeSheet) { state in + TravelAlbumCodeSheet(state: state) + } + .onChange(of: viewModel.errorMessage) { message in + guard let message, !message.isEmpty, !viewModel.showCreateSheet else { return } + toastCenter.show(message) + } + } + + private var createHeroCard: some View { + Button { + Task { + await viewModel.openCreateSheet( + api: travelAlbumAPI, + scenicSelected: accountContext.currentScenic != nil + ) + } + } label: { + HStack(spacing: AppMetrics.Spacing.medium) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(AppDesign.primary) + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.isCreating ? "新建中..." : "新建相册任务") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text("创建旅拍相册并开始有线传图") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(AppDesign.placeholder) + } + .padding(AppMetrics.Spacing.large) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + .buttonStyle(.plain) + .disabled(viewModel.isCreating) + } + + private func reload(showLoading: Bool) async { + if showLoading { globalLoading.show() } + defer { if showLoading { globalLoading.hide() } } + await viewModel.loadAlbums(api: travelAlbumAPI) + } +} + +private struct TravelAlbumCard: View { + let album: TravelAlbumItem + let onOpenDetail: () -> Void + let onOpenWiredTransfer: () -> Void + 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) } + } + .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") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + Text(album.type == 1 ? "先拍再买" : "买了再拍") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.primary) + } + Spacer() + } + + HStack(spacing: AppMetrics.Spacing.small) { + NavigationLink { + WiredCameraTransferView(context: WiredTransferContext( + albumId: album.id, + albumName: album.name, + phone: album.displayPhone, + orderNumber: album.orderNumber + )) + .onAppear { onOpenWiredTransfer() } + } label: { + actionChip(title: "拍传", systemImage: "cable.connector") + } + + Button(action: onOpenCode) { + actionChip(title: "相册码", systemImage: "qrcode") + } + + NavigationLink { + TravelAlbumDetailView(albumID: album.id) + .onAppear { onOpenDetail() } + } label: { + actionChip(title: "详情", systemImage: "info.circle") + } + } + } + .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) { + Image(systemName: systemImage) + Text(title) + } + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(AppDesign.primary.opacity(0.08)) + .clipShape(Capsule()) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift new file mode 100644 index 0000000..8bc58f4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift @@ -0,0 +1,301 @@ +// +// WiredCameraTransferView.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI +import UIKit + +/// 有线传图页,连接相机并上传照片到旅拍相册。 +struct WiredCameraTransferView: View { + let context: WiredTransferContext + + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.travelAlbumAPI) private var travelAlbumAPI + @Environment(\.ossUploadService) private var ossUploadService + @EnvironmentObject private var toastCenter: ToastCenter + + @StateObject private var viewModel: WiredCameraTransferViewModel + + init(context: WiredTransferContext) { + self.context = context + _viewModel = StateObject( + wrappedValue: WiredCameraTransferViewModel( + context: context, + cameraService: CameraServiceFactory.make(brand: .sony) + ) + ) + } + + private let tabTitles = ["全部", "已上传", "失败"] + + var body: some View { + VStack(spacing: 0) { + headerSection + tabSection + photoListSection + bottomBar + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("有线传图") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.start( + api: travelAlbumAPI, + ossService: ossUploadService, + scenicID: accountContext.currentScenic?.id ?? 0, + accountContext: accountContext + ) + } + .onDisappear { + Task { await viewModel.stop() } + } + .onChange(of: viewModel.errorMessage) { message in + guard let message, !message.isEmpty else { return } + toastCenter.show(message) + } + .onChange(of: viewModel.connectionState) { state in + switch state { + case .connected(let name): + toastCenter.show("已连接 \(name)") + case .disconnected: + break + case .error(let message): + toastCenter.show(message) + default: + break + } + } + .confirmationDialog("指定上传", isPresented: $viewModel.showSpecifyUploadSheet, titleVisibility: .visible) { + Button("上传全部待上传") { + Task { await viewModel.batchUploadAll() } + } + Button("上传选中项") { + Task { await viewModel.uploadSelected() } + } + Button("取消", role: .cancel) {} + } + } + + private var headerSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(context.albumName) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + if !context.phone.isEmpty { + Text(context.phone) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + } + Spacer() + connectionBadge + } + + Picker("传输模式", selection: Binding( + get: { viewModel.transferModeOption }, + set: { newValue in + viewModel.selectTransferMode( + newValue, + api: travelAlbumAPI, + ossService: ossUploadService, + scenicID: accountContext.currentScenic?.id ?? 0 + ) + } + )) { + Text(WiredCameraTransferViewModel.modeLiveCapture).tag(WiredCameraTransferViewModel.modeLiveCapture) + Text(WiredCameraTransferViewModel.modePostShootTransfer).tag(WiredCameraTransferViewModel.modePostShootTransfer) + } + .pickerStyle(.segmented) + } + .padding(AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + .background(Color.white) + } + + private var connectionBadge: some View { + VStack(spacing: 4) { + Circle() + .stroke(connectionColor, lineWidth: 3) + .frame(width: 44, height: 44) + .overlay { + Image(systemName: "camera.fill") + .foregroundStyle(connectionColor) + } + Text(connectionText) + .font(.system(size: 10)) + .foregroundStyle(AppDesign.textSecondary) + .multilineTextAlignment(.center) + .frame(width: 72) + } + } + + private var connectionColor: Color { + switch viewModel.connectionState { + case .connected: AppDesign.success + case .error: Color.red + case .searching, .connecting: AppDesign.warning + case .disconnected: AppDesign.placeholder + } + } + + private var connectionText: String { + switch viewModel.connectionState { + case .connected(let name): + name + default: + viewModel.connectionState.displayText + } + } + + private var tabSection: some View { + let counts = viewModel.photos.transferTabCounts + return HStack(spacing: 0) { + ForEach(Array(tabTitles.enumerated()), id: \.offset) { index, title in + Button { + viewModel.selectedTabIndex = index + } label: { + VStack(spacing: 4) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedTabIndex == index ? .semibold : .regular)) + Text("\(counts[index])") + .font(.system(size: AppMetrics.FontSize.caption)) + } + .foregroundStyle(viewModel.selectedTabIndex == index ? AppDesign.primary : AppDesign.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(viewModel.selectedTabIndex == index ? AppDesign.primarySoft : Color.clear) + } + .buttonStyle(.plain) + } + } + .background(Color.white) + } + + private var photoListSection: some View { + let visiblePhotos = viewModel.photos.filtered(byTabIndex: viewModel.selectedTabIndex) + return ScrollView { + if visiblePhotos.isEmpty { + AppContentUnavailableView("暂无照片", systemImage: "photo") + .frame(maxWidth: .infinity, minHeight: 260) + } else { + LazyVStack(spacing: AppMetrics.Spacing.small) { + ForEach(visiblePhotos) { photo in + photoRow(photo) + } + } + .padding(AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + } + } + + private func photoRow(_ photo: WiredTransferPhotoItem) -> some View { + HStack(spacing: AppMetrics.Spacing.medium) { + if viewModel.selectUploadMode, photo.canSelectForSpecifyUpload { + Button { + viewModel.togglePhotoSelection(id: photo.id) + } label: { + Image(systemName: viewModel.selectedPhotoIDs.contains(photo.id) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(viewModel.selectedPhotoIDs.contains(photo.id) ? AppDesign.primary : AppDesign.placeholder) + } + .buttonStyle(.plain) + } + + thumbnailView(for: photo) + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + Text(photo.fileName) + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .lineLimit(1) + Text(photo.capturedAt) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + HStack { + Text(photo.status.displayLabel) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(statusColor(photo.status)) + if photo.status == .uploading || photo.status == .transferring { + Text("\(photo.progress)%") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + } + } + + Spacer() + + if photo.status == .failed { + Button("重试") { + Task { await viewModel.retryPhoto(id: photo.id) } + } + .font(.system(size: AppMetrics.FontSize.caption)) + } + } + .padding(AppMetrics.Spacing.medium) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + @ViewBuilder + private func thumbnailView(for photo: WiredTransferPhotoItem) -> some View { + if photo.thumbnailURL.hasPrefix("file://"), let url = URL(string: photo.thumbnailURL) { + if let uiImage = UIImage(contentsOfFile: url.path) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + placeholderThumbnail + } + } else { + RemoteImage(urlString: photo.thumbnailURL) { + placeholderThumbnail + } + } + } + + private var placeholderThumbnail: some View { + Color(hex: 0xE5E7EB) + .overlay { + Image(systemName: "photo") + .foregroundStyle(AppDesign.placeholder) + } + } + + private func statusColor(_ status: WiredTransferUploadStatus) -> Color { + switch status { + case .uploaded: AppDesign.success + case .failed: Color.red + case .uploading, .transferring: AppDesign.warning + case .pending: AppDesign.textSecondary + } + } + + private var bottomBar: some View { + HStack(spacing: AppMetrics.Spacing.small) { + Button("同步历史") { + Task { await viewModel.syncExistingPhotos() } + } + .buttonStyle(.bordered) + + Button("批量上传") { + Task { await viewModel.batchUploadAll() } + } + .buttonStyle(.borderedProminent) + + Button("指定上传") { + viewModel.selectUploadMode = true + viewModel.showSpecifyUploadSheet = true + } + .buttonStyle(.bordered) + } + .padding(AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + .background(Color.white) + } +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 9e281b3..9e94e4b 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -41,15 +41,8 @@ remote-notification - UILaunchScreen - - UIColorName - LaunchBackground - UIImageName - SplashLogo - UIImageRespectsSafeAreaInsets - - + UILaunchStoryboardName + Launch Screen UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/suixinkan/Launch Screen.storyboard b/suixinkan/Launch Screen.storyboard new file mode 100644 index 0000000..7f528a2 --- /dev/null +++ b/suixinkan/Launch Screen.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift new file mode 100644 index 0000000..12ed5b7 --- /dev/null +++ b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift @@ -0,0 +1,70 @@ +// +// CameraTransferPipelineTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 相机传输管道测试,使用 Mock 相机与上传 Sink。 +final class CameraTransferPipelineTests: XCTestCase { + /// 测试新照片下载后触发上传 Sink。 + func testNewAssetTriggersUploadSink() async { + let camera = MockCameraService() + let sink = MockUploadSink() + let pipeline = CameraTransferPipeline(cameraService: camera) + pipeline.configure(uploadSink: sink, uploadEnabled: true) + + let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024) + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG") + try? Data(repeating: 0xFF, count: 8).write(to: tempURL) + + camera.onNewAsset?(asset) + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertEqual(sink.uploadCount, 1) + try? FileManager.default.removeItem(at: tempURL) + } +} + +@MainActor +private final class MockCameraService: CameraServiceProtocol { + let brand: CameraBrand = .sony + var connectionState: CameraConnectionState = .connected(deviceName: "Mock") + var onConnectionStateChange: ((CameraConnectionState) -> Void)? + var onNewAsset: ((CameraAsset) -> Void)? + + func connect() async {} + func disconnect() async {} + + func listAssets() async throws -> [CameraAsset] { + [] + } + + func downloadAsset(_ asset: CameraAsset) async throws -> URL { + 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 MockUploadSink: CameraAssetUploadSink { + var uploadCount = 0 + + func upload( + localURL: URL, + fileName: String, + fileType: Int, + progress: @escaping @Sendable (Int) -> Void + ) async throws -> String { + uploadCount += 1 + progress(100) + return "https://cdn/mock/\(fileName)" + } +} diff --git a/suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift b/suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift new file mode 100644 index 0000000..5ae6327 --- /dev/null +++ b/suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift @@ -0,0 +1,63 @@ +// +// SonyPTPCommandsTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +/// Sony PTP 命令构建与解析测试。 +final class SonyPTPCommandsTests: XCTestCase { + /// 测试 OpenSession 命令包长度与 opcode。 + func testBuildOpenSessionCommand() { + let data = SonyPTPHelper.buildCommand( + code: SonyPTPCommand.openSession, + transactionID: 1, + parameters: [1] + ) + + XCTAssertEqual(data.count, 16) + XCTAssertEqual(data[6], 0x02) + XCTAssertEqual(data[7], 0x10) + } + + /// 测试 SDIO Connect 命令 opcode。 + func testBuildSonyConnectCommand() { + let data = SonyPTPHelper.buildCommand( + code: SonyPTPCommand.sdioConnect, + transactionID: 42, + parameters: [1, 0, 0] + ) + + XCTAssertEqual(data.count, 24) + XCTAssertEqual(data[6], 0x01) + XCTAssertEqual(data[7], 0x92) + } + + /// 测试 GetObject 压缩大小解析。 + func testParseObjectCompressedSize() { + var data = Data(repeating: 0, count: 52) + data[8] = 0x00 + data[9] = 0x10 + data[10] = 0x00 + data[11] = 0x00 + + XCTAssertEqual(SonyPTPHelper.parseObjectCompressedSize(data), 0x1000) + } + + /// 测试 SDIE ObjectAdded 事件解析。 + func testParseObjectAddedEvent() throws { + var event = Data() + event.append(contentsOf: [0x10, 0x00, 0x00, 0x00]) + event.append(contentsOf: [0x04, 0x00]) + event.append(contentsOf: [0x01, 0xC2]) + event.append(contentsOf: [0xFF, 0xFF, 0xFF, 0xFF]) + event.append(contentsOf: [0x01, 0xC0, 0xFF, 0xFF]) + + let container = try SonyPTPHelper.parseContainer(event) + XCTAssertEqual(container.code, SonyPTPCommand.sdieObjectAdded) + XCTAssertEqual(container.params.first, SonyPTPCommand.shotObjectHandle) + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index ff7a565..525831c 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -65,6 +65,7 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "travel_album", title: ""), .destination(.travelAlbumEntry)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification)) } @@ -212,6 +213,7 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "travel_album", title: ""), .destination(.travelAlbumEntry)) } #endif diff --git a/suixinkanTests/ProfileSpace/ProfileSpaceTests.swift b/suixinkanTests/ProfileSpace/ProfileSpaceTests.swift new file mode 100644 index 0000000..d5f90a3 --- /dev/null +++ b/suixinkanTests/ProfileSpace/ProfileSpaceTests.swift @@ -0,0 +1,351 @@ +// +// ProfileSpaceTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 个人空间配置测试,覆盖 API、ViewModel 校验和首页路由接入。 +final class ProfileSpaceTests: XCTestCase { + /// 测试个人空间信息接口路径、查询参数和响应解码。 + func testProfileSpaceInfoAPIRequestsExpectedEndpoint() async throws { + let session = ProfileSpaceURLSession(responses: [Self.profileInfoEnvelope]) + let api = ProfileAPI(client: APIClient(session: session)) + + let response = try await api.profileSpaceInfo(scenicId: 9) + + XCTAssertEqual(response.nickname, "云端摄影师") + XCTAssertEqual(response.cameraDevice, ["Sony A7M4"]) + XCTAssertEqual(response.schedule["2026-06-29"]?.first?.name, "上午拍摄") + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/profile/info") + let queryItems = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(queryItems?.first(where: { $0.name == "scenic_id" })?.value, "9") + } + + /// 测试个人空间更新接口使用正确路径和请求体。 + func testUpdateProfileSpaceAPIEncodesExpectedBody() async throws { + let session = ProfileSpaceURLSession(responses: [Self.emptyEnvelope]) + let api = ProfileAPI(client: APIClient(session: session)) + let request = UpdateProfileSpaceRequest( + scenicId: "9", + description: "简介", + cameraDevice: ["Sony"], + attrLabel: ["亲和"], + shootLabel: ["夜景"], + businessStartTime: "09:00:00", + businessEndTime: "18:00:00", + holidayStartTime: "10:00:00", + holidayEndTime: "20:00:00", + businessRange: [], + acceptOrderStatus: 1 + ) + + try await api.updateProfileSpaceInfo(request) + + let urlRequest = try XCTUnwrap(session.requests.first) + XCTAssertEqual(urlRequest.httpMethod, "POST") + XCTAssertEqual(urlRequest.url?.path, "/api/yf-handset-app/photog/profile/info-update") + let body = try XCTUnwrap(urlRequest.httpBody) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + XCTAssertEqual(json?["scenic_id"] as? String, "9") + XCTAssertEqual(json?["description"] as? String, "简介") + XCTAssertEqual(json?["accept_order_status"] as? Int, 1) + XCTAssertEqual(json?["business_range"] as? [Int], []) + } + + /// 测试加载成功后回填页面状态。 + func testViewModelLoadAppliesProfileSpaceState() async throws { + let api = MockProfileSpaceAPI() + let viewModel = ProfileSpaceViewModel() + + await viewModel.load(api: api, scenicId: 9) + + XCTAssertEqual(viewModel.nickname, "云端摄影师") + XCTAssertEqual(viewModel.attrLabels, ["亲和", "构图"]) + XCTAssertEqual(viewModel.cameraDevices, ["Sony A7M4"]) + XCTAssertEqual(viewModel.acceptOrderStatus, 1) + XCTAssertEqual(viewModel.scheduleMap["2026-06-29"]?.count, 1) + XCTAssertFalse(viewModel.hasChanges) + } + + /// 测试缺少景区时不发请求并展示错误。 + func testViewModelLoadWithoutScenicDoesNotRequest() async { + let api = MockProfileSpaceAPI() + let viewModel = ProfileSpaceViewModel() + + await viewModel.load(api: api, scenicId: nil) + + XCTAssertEqual(api.infoRequests, []) + XCTAssertEqual(viewModel.errorMessage, "当前缺少景区信息") + } + + /// 测试标签校验覆盖空值、重复、超长和数量边界。 + func testViewModelValidatesLabels() async throws { + let viewModel = ProfileSpaceViewModel() + + XCTAssertThrowsError(try viewModel.addAttrLabel(" ")) { error in + XCTAssertEqual(error as? ProfileSpaceValidationError, .emptyLabel) + } + + try viewModel.addAttrLabel("亲和") + XCTAssertThrowsError(try viewModel.addAttrLabel("亲和")) { error in + XCTAssertEqual(error as? ProfileSpaceValidationError, .duplicateLabel) + } + XCTAssertThrowsError(try viewModel.addAttrLabel(String(repeating: "长", count: 21))) { error in + XCTAssertEqual(error as? ProfileSpaceValidationError, .labelTooLong) + } + + for index in 0..<7 { + try viewModel.addAttrLabel("标签\(index)") + } + XCTAssertThrowsError(try viewModel.addAttrLabel("第九个")) { error in + XCTAssertEqual(error as? ProfileSpaceValidationError, .labelLimit) + } + } + + /// 测试营业时间必须开始早于结束。 + func testViewModelValidatesBusinessTimes() async throws { + let api = MockProfileSpaceAPI() + let viewModel = ProfileSpaceViewModel() + await viewModel.load(api: api, scenicId: 9) + viewModel.businessStartTime = Self.time(hour: 18, minute: 0) + viewModel.businessEndTime = Self.time(hour: 9, minute: 0) + + XCTAssertThrowsError(try viewModel.makeUpdateRequest(scenicId: 9)) { error in + XCTAssertEqual(error as? ProfileSpaceValidationError, .invalidWorkdayTime) + } + } + + /// 测试保存成功后清除 dirty 状态并提交更新请求。 + func testViewModelSaveClearsDirtyState() async throws { + let api = MockProfileSpaceAPI() + let uploader = ProfileSpaceMockUploader() + let viewModel = ProfileSpaceViewModel() + await viewModel.load(api: api, scenicId: 9) + viewModel.introduction = "新的简介" + + try await viewModel.save(api: api, uploader: uploader, scenicId: 9) + + XCTAssertEqual(api.updateRequests.count, 1) + XCTAssertEqual(api.updateRequests.first?.description, "新的简介") + XCTAssertFalse(viewModel.hasChanges) + } + + /// 测试保存失败时保留编辑内容并暴露错误。 + func testViewModelSaveFailureKeepsDraft() async throws { + let api = MockProfileSpaceAPI() + let uploader = ProfileSpaceMockUploader() + let viewModel = ProfileSpaceViewModel() + await viewModel.load(api: api, scenicId: 9) + viewModel.introduction = "保留草稿" + api.updateError = APIError.serverCode(500, "保存失败") + + do { + try await viewModel.save(api: api, uploader: uploader, scenicId: 9) + XCTFail("保存失败应抛出错误") + } catch { + XCTAssertEqual(viewModel.introduction, "保留草稿") + XCTAssertEqual(viewModel.errorMessage, error.localizedDescription) + XCTAssertTrue(viewModel.hasChanges) + } + } + + /// 测试删除日程会调用 ScheduleAPI 并刷新空间配置。 + func testViewModelDeleteScheduleCallsScheduleAPIAndReloads() async throws { + let api = MockProfileSpaceAPI() + let scheduleAPI = MockProfileSpaceScheduleAPI() + let viewModel = ProfileSpaceViewModel() + await viewModel.load(api: api, scenicId: 9) + let item = try XCTUnwrap(viewModel.scheduleMap["2026-06-29"]?.first) + + await viewModel.deleteSchedule(item: item, profileAPI: api, scheduleAPI: scheduleAPI, scenicId: 9) + + XCTAssertEqual(scheduleAPI.deletedIds, [item.id]) + XCTAssertEqual(api.infoRequests, [9, 9]) + } + + /// 测试首页空间设置和基本信息路由拆分正确。 + func testHomeRoutesResolveProfileSpaceAndBasicInfoSeparately() { + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "space_settings", title: "空间设置"), + .destination(.profileSpaceSettings) + ) + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "basic_info", title: "基本信息"), + .destination(.profileSpace) + ) + } + + fileprivate static var profileInfoEnvelope: Data { + """ + { + "code": 100000, + "msg": "success", + "data": { + "id": "1", + "scenic_id": "9", + "photog_uid": 88, + "real_name": "测试摄影师", + "nickname": "云端摄影师", + "avatar": "https://cdn.example.com/avatar.jpg", + "scenic_certification": ["官方认证"], + "description": "擅长旅拍", + "attr_label": ["亲和", "构图"], + "shoot_label": ["夜景"], + "camera_device": ["Sony A7M4"], + "business_start_time": "09:00:00", + "business_end_time": "18:00:00", + "holiday_start_time": "10:00:00", + "holiday_end_time": "20:00:00", + "business_range": [1, 2], + "accept_order_status": 1, + "schedule": { + "2026-06-29": [ + { + "id": 31, + "name": "上午拍摄", + "remark": "东门集合", + "start_time": "09:00:00", + "end_time": "10:00:00", + "schedule_date": "2026-06-29", + "order_number": "NO123", + "user_phone": "13800000000" + } + ] + } + } + } + """.data(using: .utf8)! + } + + fileprivate static var emptyEnvelope: Data { + #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)! + } + + private static func time(hour: Int, minute: Int) -> Date { + Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date() + } +} + +/// 个人空间 API 测试用 URLSession 替身。 +private final class ProfileSpaceURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} + +@MainActor +/// 个人空间 ViewModel 测试用 API 替身。 +private final class MockProfileSpaceAPI: ProfileSpaceServing { + var updateError: Error? + private(set) var infoRequests: [Int] = [] + private(set) var updateRequests: [UpdateProfileSpaceRequest] = [] + private(set) var nicknameUpdates: [String?] = [] + private(set) var avatarUpdates: [String] = [] + + /// 返回固定个人空间配置。 + func profileSpaceInfo(scenicId: Int) async throws -> ProfileSpaceInfoResponse { + infoRequests.append(scenicId) + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: ProfileSpaceTests.profileInfoEnvelope) + return try XCTUnwrap(envelope.data) + } + + /// 记录空间配置更新请求。 + func updateProfileSpaceInfo(_ request: UpdateProfileSpaceRequest) async throws { + if let updateError { throw updateError } + updateRequests.append(request) + } + + /// 记录昵称更新请求。 + func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws { + nicknameUpdates.append(nickname) + } + + /// 记录头像 URL 更新请求。 + func updateUserAvatarURL(_ fileURL: String) async throws { + avatarUpdates.append(fileURL) + } +} + +@MainActor +/// 个人空间日程测试用 API 替身。 +private final class MockProfileSpaceScheduleAPI: ScheduleServing { + private(set) var deletedIds: [Int] = [] + + /// 返回空月标记。 + func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] { + [] + } + + /// 返回空日程列表。 + func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] { + [] + } + + /// 新增日程测试不使用。 + func addSchedule(_ request: AddScheduleRequest) async throws {} + + /// 记录删除的日程 ID。 + func deleteSchedule(id: Int) async throws { + deletedIds.append(id) + } + + /// 返回空可关联订单。 + func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] { + [] + } +} + +@MainActor +/// 个人空间上传测试替身。 +private final class ProfileSpaceMockUploader: OSSUploadServing { + /// 上传头像并返回固定 URL。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + onProgress(100) + return "https://cdn.example.com/new-avatar.jpg" + } + + /// 实名图片上传测试不使用。 + 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 { "" } + + /// 任务附件上传测试不使用。 + 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 { "" } +} diff --git a/suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift new file mode 100644 index 0000000..6fcb12b --- /dev/null +++ b/suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift @@ -0,0 +1,100 @@ +// +// TravelAlbumAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 旅拍相册 API 测试,覆盖 path、query 与 body。 +final class TravelAlbumAPITests: XCTestCase { + /// 测试列表与创建接口 path。 + func testAlbumListAndCreateUseExpectedPaths() async throws { + let session = TravelAlbumRecordingURLSession(responses: [Self.listResponse, Self.createResponse]) + let api = TravelAlbumAPI(client: APIClient(session: session)) + + let list = try await api.albumList(page: 1, pageSize: 20) + _ = try await api.createAlbum( + TravelAlbumCreateRequest( + name: "2026-06-29-001", + type: 1, + orderNumber: nil, + materialNum: 3, + materialPrice: 9.9, + materialPackagePrice: 99, + photoPrice: 0 + ) + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/travel-album/list", + "/api/yf-handset-app/photog/travel-album/create" + ]) + XCTAssertEqual(list.total, 1) + XCTAssertEqual(list.list.first?.name, "2026-06-29-001") + + let createBody = try bodyObject(from: session.requests[1]) + XCTAssertEqual(createBody["name"] as? String, "2026-06-29-001") + XCTAssertEqual(createBody["type"] as? Int, 1) + } + + /// 测试上传素材登记接口 body。 + func testUploadMaterialUsesExpectedBody() async throws { + let session = TravelAlbumRecordingURLSession(data: Self.uploadMaterialResponse) + let api = TravelAlbumAPI(client: APIClient(session: session)) + + _ = try await api.uploadMaterial( + TravelAlbumUploadMaterialRequest( + userEquityTravelId: 8, + fileName: "DSC_001.JPG", + fileURL: "https://cdn/a.jpg" + ) + ) + + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/travel-album/upload-material") + let body = try bodyObject(from: try XCTUnwrap(session.requests.first)) + XCTAssertEqual(body["user_equity_travel_id"] as? Int, 8) + XCTAssertEqual(body["file_name"] as? String, "DSC_001.JPG") + XCTAssertEqual(body["file_url"] as? String, "https://cdn/a.jpg") + } + + fileprivate static let listResponse = Data( + #"{"code":100000,"msg":"success","data":{"total":"1","list":[{"id":"8","store_user_id":"1","name":"2026-06-29-001","type":"1","order_number":"","material_num":"3","material_price":"990","material_package_price":"9900","photo_price":"0","cover_url":"","user_id":"100","status":"1","created_at":"2026","updated_at":"2026","user":{"id":"200","phone":"13800138000"}}]}}"#.utf8 + ) + + fileprivate static let createResponse = Data( + #"{"code":100000,"msg":"success","data":{"id":"9"}}"#.utf8 + ) + + fileprivate static let uploadMaterialResponse = 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 + ) +} + +private final class TravelAlbumRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(data: Data) { + responses = [data] + } + + init(responses: [Data]) { + self.responses = responses + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? TravelAlbumAPITests.listResponse : responses.removeFirst() + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +private func bodyObject(from request: URLRequest) throws -> [String: Any] { + let data = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) +} diff --git a/suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift new file mode 100644 index 0000000..7dd928d --- /dev/null +++ b/suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift @@ -0,0 +1,124 @@ +// +// TravelAlbumViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 旅拍相册 ViewModel 测试。 +final class TravelAlbumViewModelTests: XCTestCase { + /// 测试先拍再买模式缺少单价时创建失败。 + func testCreatePreShootRequiresSinglePrice() async { + let viewModel = TravelAlbumEntryViewModel() + let api = MockTravelAlbumAPI() + + let success = await viewModel.confirmCreateTask( + api: api, + mode: TravelAlbumEntryViewModel.modePreShoot, + freeCount: "3", + singlePrice: "", + packagePrice: "99", + order: nil, + scenicSelected: true + ) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.errorMessage, "请输入单张照片价格") + XCTAssertEqual(api.createCallCount, 0) + } + + /// 测试买了再拍模式缺少订单时创建失败。 + func testCreatePreOrderRequiresOrder() async { + let viewModel = TravelAlbumEntryViewModel() + let api = MockTravelAlbumAPI() + + let success = await viewModel.confirmCreateTask( + api: api, + mode: TravelAlbumEntryViewModel.modePreOrder, + freeCount: "", + singlePrice: "", + packagePrice: "", + order: nil, + scenicSelected: true + ) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.errorMessage, "请选择绑定订单") + } + + /// 测试创建成功后刷新列表。 + func testCreateSuccessReloadsList() async { + let viewModel = TravelAlbumEntryViewModel() + let api = MockTravelAlbumAPI() + + let success = await viewModel.confirmCreateTask( + api: api, + mode: TravelAlbumEntryViewModel.modePreShoot, + freeCount: "3", + singlePrice: "9.9", + packagePrice: "99", + order: nil, + scenicSelected: true + ) + + XCTAssertTrue(success) + XCTAssertEqual(api.createCallCount, 1) + XCTAssertEqual(api.listCallCount, 1) + } + + /// 测试相册名称按日期递增。 + func testBuildTodayAlbumNameIncrementsSuffix() { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let today = formatter.string(from: Date()) + + let name = TravelAlbumEntryViewModel.buildTodayAlbumName( + existingNames: ["\(today)-001", "\(today)-002", "2026-06-28-001"] + ) + XCTAssertTrue(name.hasSuffix("003")) + } +} + +@MainActor +private final class MockTravelAlbumAPI: TravelAlbumServing { + var createCallCount = 0 + var listCallCount = 0 + + func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] } + + func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse { + createCallCount += 1 + return TravelAlbumCreateResponse(id: 9) + } + + func albumList(page: Int, pageSize: Int) async throws -> ListPayload { + listCallCount += 1 + return 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 { + ListPayload(total: 0, list: []) + } + + func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { + throw APIError.emptyData + } + + func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {} + + func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { + TravelAlbumMpCodeResponse(mpCodeOssURL: "") + } +} diff --git a/suixinkanUITests/Support/UITestHomeMenuCatalog.swift b/suixinkanUITests/Support/UITestHomeMenuCatalog.swift index e949e54..6b80791 100644 --- a/suixinkanUITests/Support/UITestHomeMenuCatalog.swift +++ b/suixinkanUITests/Support/UITestHomeMenuCatalog.swift @@ -26,7 +26,7 @@ enum UITestHomeMenuCatalog { /// 去重后的首页路由覆盖表,同义 URI 只保留一个菜单标题。 static let entries: [Entry] = [ - Entry(menuTitle: "空间设置", expectation: .navigationTitle("个人信息")), + Entry(menuTitle: "空间设置", expectation: .navigationTitle("个人空间配置")), Entry(menuTitle: "相册管理", expectation: .navigationTitle("相册管理")), Entry(menuTitle: "相册预览上传", expectation: .navigationTitle("相册预览上传")), Entry(menuTitle: "我的钱包", expectation: .navigationTitle("我的钱包")), diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 01385d6..deab964 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -86,6 +86,7 @@ | 状态 | 模块 | 当前情况 | 后续事项 | | --- | --- | --- | --- | +| 完成 | 空间设置 | `space_settings` 已接真实“个人空间配置”页面;支持资料/头像/简介、标签、拍摄说明、营业时间、相机设备、接单状态和未来 7 天日程展示;日程新增/删除复用 Schedule 模块。 | 相册管理继续由相册模块承接,不在空间设置页重复迁移。 | | 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 | | 完成 | 提现审核 | 首页入口已接真实页面,复用提现记录接口展示审核列表、状态筛选、分页和详情时间线。 | 不做管理员审批操作,旧 Android 未体现对应操作接口。 | | 完成 | 消息中心 | 消息列表、未读筛选、游标分页、已读/全部已读、详情和删除已接入。 | 本轮不新增后端未体现的批量接口。 | @@ -101,6 +102,7 @@ | 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | | 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | | 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | +| 完成 | 旅拍相册 | `travel_album` 已接真实页面;支持新建相册任务、相册详情、小程序码、Sony USB 有线传图、OSS 上传与 upload-material 登记。 | RAW 预览、设备存储弹窗等 Android 细节可二期补齐;需真机 + Sony 相机联调 OTG。 | | 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 | | 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 | | 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制外部推流地址、开始/暂停/结束、推流模式切换、直播详情可播放 URL 预览、直播相册视频播放,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 已按旧 iOS 对齐:不新增真推流 SDK,`push_url` 仅作为 OBS/第三方工具外部推流地址;页面保留推流诊断适配层,真 RTMP/RTC 推流 SDK 仍需后续单独选型和真机联调。 |