Add TravelAlbum, ProfileSpace, and wired camera transfer modules.
Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -123,6 +123,10 @@ private struct TaskAPIEnvironmentKey: EnvironmentKey {
|
|||||||
static var defaultValue: TaskAPI { TaskAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
static var defaultValue: TaskAPI { TaskAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct TravelAlbumAPIEnvironmentKey: EnvironmentKey {
|
||||||
|
static var defaultValue: TravelAlbumAPI { TravelAlbumAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||||
|
}
|
||||||
|
|
||||||
private struct WalletAPIEnvironmentKey: EnvironmentKey {
|
private struct WalletAPIEnvironmentKey: EnvironmentKey {
|
||||||
static var defaultValue: WalletAPI { WalletAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
static var defaultValue: WalletAPI { WalletAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||||
}
|
}
|
||||||
@ -267,6 +271,11 @@ extension EnvironmentValues {
|
|||||||
set { self[TaskAPIEnvironmentKey.self] = newValue }
|
set { self[TaskAPIEnvironmentKey.self] = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var travelAlbumAPI: TravelAlbumAPI {
|
||||||
|
get { self[TravelAlbumAPIEnvironmentKey.self] }
|
||||||
|
set { self[TravelAlbumAPIEnvironmentKey.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
var walletAPI: WalletAPI {
|
var walletAPI: WalletAPI {
|
||||||
get { self[WalletAPIEnvironmentKey.self] }
|
get { self[WalletAPIEnvironmentKey.self] }
|
||||||
set { self[WalletAPIEnvironmentKey.self] = newValue }
|
set { self[WalletAPIEnvironmentKey.self] = newValue }
|
||||||
|
|||||||
27
suixinkan/Core/CameraTethering/CameraTethering.md
Normal file
27
suixinkan/Core/CameraTethering/CameraTethering.md
Normal file
@ -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(系统框架)
|
||||||
|
- 无第三方库依赖
|
||||||
@ -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) 相机尚未实现")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
suixinkan/Core/CameraTethering/Models/CameraAsset.swift
Normal file
23
suixinkan/Core/CameraTethering/Models/CameraAsset.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
17
suixinkan/Core/CameraTethering/Models/CameraBrand.swift
Normal file
17
suixinkan/Core/CameraTethering/Models/CameraBrand.swift
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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<URL, Error>] = [:]
|
||||||
|
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<Void, Never>?
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
162
suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift
Normal file
162
suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift
Normal file
@ -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<String> = []
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
571
suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift
Normal file
571
suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift
Normal file
@ -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..<charCount {
|
||||||
|
codeUnits.append(readUInt16LE(data, offset: offset + index * 2))
|
||||||
|
}
|
||||||
|
offset += charCount * 2
|
||||||
|
|
||||||
|
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
||||||
|
return (string, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ImageCaptureCore 已建立 PTP 会话,此处执行 Sony SDIO 握手并进入 PC Remote 模式。
|
||||||
|
static func initializeRemoteSession(on camera: ICCameraDevice, startingTransactionID: UInt32) async -> 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..<enumCount {
|
||||||
|
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
|
||||||
|
}
|
||||||
|
guard offset + 1 < data.count else { return false }
|
||||||
|
enumCount = Int(readUInt16LE(data, offset: offset))
|
||||||
|
offset += 2
|
||||||
|
for _ in 0..<enumCount {
|
||||||
|
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func configureRemoteCapture(on camera: ICCameraDevice, transactionID: inout UInt32) async {
|
||||||
|
_ = await setDeviceProperty(
|
||||||
|
on: camera,
|
||||||
|
propertyCode: SonyPTPCommand.positionKeySetting,
|
||||||
|
value: 1,
|
||||||
|
transactionID: &transactionID,
|
||||||
|
label: "Position_Key_Setting=PC Remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = await setDeviceProperty(
|
||||||
|
on: camera,
|
||||||
|
propertyCode: SonyPTPCommand.stillImageSaveDestination,
|
||||||
|
value: SonyPTPCommand.saveDestinationHostPC,
|
||||||
|
transactionID: &transactionID,
|
||||||
|
label: "Still_Image_Save_Destination=Host PC"
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = await setDevicePropertyUInt32(
|
||||||
|
on: camera,
|
||||||
|
propertyCode: SonyPTPCommand.stillImageTransSize,
|
||||||
|
value: 0xFFFF_FFFF,
|
||||||
|
transactionID: &transactionID,
|
||||||
|
label: "Still_Image_Trans_Size=Full"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setDevicePropertyUInt32(
|
||||||
|
on camera: ICCameraDevice,
|
||||||
|
propertyCode: UInt16,
|
||||||
|
value: UInt32,
|
||||||
|
transactionID: inout UInt32,
|
||||||
|
label: String
|
||||||
|
) async -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
20
suixinkan/Core/CameraTransfer/CameraAssetUploadSink.swift
Normal file
20
suixinkan/Core/CameraTransfer/CameraAssetUploadSink.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
25
suixinkan/Core/CameraTransfer/CameraTransfer.md
Normal file
25
suixinkan/Core/CameraTransfer/CameraTransfer.md
Normal file
@ -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 具体实现。
|
||||||
249
suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift
Normal file
249
suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift
Normal file
@ -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<String> = []
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
suixinkan/Core/CameraTransfer/CameraTransferTask.swift
Normal file
56
suixinkan/Core/CameraTransfer/CameraTransferTask.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ struct HomeMenuItem: Equatable, Identifiable {
|
|||||||
/// 首页可导航目标实体,表示首页菜单能进入的本地页面或占位页面。
|
/// 首页可导航目标实体,表示首页菜单能进入的本地页面或占位页面。
|
||||||
enum HomeRoute: Hashable {
|
enum HomeRoute: Hashable {
|
||||||
case profileSpace
|
case profileSpace
|
||||||
|
case profileSpaceSettings
|
||||||
case scenicSelection
|
case scenicSelection
|
||||||
case permissionApply
|
case permissionApply
|
||||||
case permissionApplyStatus
|
case permissionApplyStatus
|
||||||
@ -63,6 +64,7 @@ enum HomeRoute: Hashable {
|
|||||||
case queueManagement
|
case queueManagement
|
||||||
case liveManagement
|
case liveManagement
|
||||||
case liveAlbum
|
case liveAlbum
|
||||||
|
case travelAlbumEntry
|
||||||
case operatingArea
|
case operatingArea
|
||||||
case pilotCertification
|
case pilotCertification
|
||||||
case modulePlaceholder(uri: String, title: String)
|
case modulePlaceholder(uri: String, title: String)
|
||||||
|
|||||||
@ -35,6 +35,7 @@ enum HomeMenuRouter {
|
|||||||
"sample_upload": "上传样片",
|
"sample_upload": "上传样片",
|
||||||
"live_stream_management": "直播管理",
|
"live_stream_management": "直播管理",
|
||||||
"live_album": "直播相册",
|
"live_album": "直播相册",
|
||||||
|
"travel_album": "新增相册",
|
||||||
"scenicselection": "景区选择",
|
"scenicselection": "景区选择",
|
||||||
"scenicapplication": "景区申请",
|
"scenicapplication": "景区申请",
|
||||||
"permission_apply": "权限申请",
|
"permission_apply": "权限申请",
|
||||||
@ -76,7 +77,9 @@ enum HomeMenuRouter {
|
|||||||
return .orderPush(.writeOffList)
|
return .orderPush(.writeOffList)
|
||||||
case "photographer_stats":
|
case "photographer_stats":
|
||||||
return .tab(.statistics)
|
return .tab(.statistics)
|
||||||
case "space_settings", "basic_info":
|
case "space_settings":
|
||||||
|
return .destination(.profileSpaceSettings)
|
||||||
|
case "basic_info":
|
||||||
return .destination(.profileSpace)
|
return .destination(.profileSpace)
|
||||||
case "system_settings":
|
case "system_settings":
|
||||||
return .destination(.settings)
|
return .destination(.settings)
|
||||||
@ -146,6 +149,8 @@ enum HomeMenuRouter {
|
|||||||
return .destination(.liveManagement)
|
return .destination(.liveManagement)
|
||||||
case "live_album":
|
case "live_album":
|
||||||
return .destination(.liveAlbum)
|
return .destination(.liveAlbum)
|
||||||
|
case "travel_album", "task_new_entry":
|
||||||
|
return .destination(.travelAlbumEntry)
|
||||||
case "operating-area":
|
case "operating-area":
|
||||||
return .destination(.operatingArea)
|
return .destination(.operatingArea)
|
||||||
case "pilot_cert":
|
case "pilot_cert":
|
||||||
@ -176,6 +181,7 @@ enum HomeMenuRouter {
|
|||||||
"basic_info",
|
"basic_info",
|
||||||
"album_list",
|
"album_list",
|
||||||
"album_trailer",
|
"album_trailer",
|
||||||
|
"travel_album",
|
||||||
"wallet",
|
"wallet",
|
||||||
"payment_collection",
|
"payment_collection",
|
||||||
"payment_qr",
|
"payment_qr",
|
||||||
|
|||||||
@ -48,6 +48,8 @@ enum HomeIconCatalog {
|
|||||||
"checkmark.seal.fill"
|
"checkmark.seal.fill"
|
||||||
case "live_album":
|
case "live_album":
|
||||||
"play.rectangle.on.rectangle.fill"
|
"play.rectangle.on.rectangle.fill"
|
||||||
|
case "travel_album", "task_new_entry":
|
||||||
|
"photo.on.rectangle.angled"
|
||||||
case "pm", "pm_manager", "project_edit":
|
case "pm", "pm_manager", "project_edit":
|
||||||
"circle.grid.2x2.fill"
|
"circle.grid.2x2.fill"
|
||||||
case "location_report", "location_report_history":
|
case "location_report", "location_report_history":
|
||||||
|
|||||||
@ -14,6 +14,8 @@ extension HomeRoute {
|
|||||||
switch self {
|
switch self {
|
||||||
case .profileSpace:
|
case .profileSpace:
|
||||||
ProfileView()
|
ProfileView()
|
||||||
|
case .profileSpaceSettings:
|
||||||
|
ProfileSpaceView()
|
||||||
case .scenicSelection:
|
case .scenicSelection:
|
||||||
ScenicSelectionView()
|
ScenicSelectionView()
|
||||||
case .permissionApply:
|
case .permissionApply:
|
||||||
@ -102,6 +104,8 @@ extension HomeRoute {
|
|||||||
LiveManagementView()
|
LiveManagementView()
|
||||||
case .liveAlbum:
|
case .liveAlbum:
|
||||||
LiveAlbumView()
|
LiveAlbumView()
|
||||||
|
case .travelAlbumEntry:
|
||||||
|
TravelAlbumEntryView()
|
||||||
case .operatingArea:
|
case .operatingArea:
|
||||||
OperatingAreaView()
|
OperatingAreaView()
|
||||||
case .pilotCertification:
|
case .pilotCertification:
|
||||||
|
|||||||
@ -8,6 +8,22 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
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
|
@MainActor
|
||||||
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
||||||
final class ProfileAPI {
|
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 {
|
func realNameSmsVerifyCode() async throws {
|
||||||
let _: EmptyPayload = try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
@ -95,3 +133,4 @@ final class ProfileAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileAPI: UserProfileServing {}
|
extension ProfileAPI: UserProfileServing {}
|
||||||
|
extension ProfileAPI: ProfileSpaceServing {}
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。
|
Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。
|
||||||
|
|
||||||
|
首页“空间设置”入口对应的是摄影师当前景区下的个人空间配置,已拆到 `ProfileSpace` 模块;`Profile` 模块只保留账号级个人资料能力。
|
||||||
|
|
||||||
该模块聚焦个人资料业务:
|
该模块聚焦个人资料业务:
|
||||||
- 拉取用户基础资料。
|
- 拉取用户基础资料。
|
||||||
- 拉取实名认证信息。
|
- 拉取实名认证信息。
|
||||||
@ -35,6 +37,8 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
|
|||||||
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
||||||
- `RealNameAuthRequest`:实名认证提交请求体。
|
- `RealNameAuthRequest`:实名认证提交请求体。
|
||||||
|
|
||||||
|
`basic_info` 首页权限入口继续进入 `ProfileView`;`space_settings` 进入 `ProfileSpaceView`,两者不共用页面状态。
|
||||||
|
|
||||||
## 加载流程
|
## 加载流程
|
||||||
|
|
||||||
1. `ProfileView.task` 进入页面时调用 `reloadProfile()`。
|
1. `ProfileView.task` 进入页面时调用 `reloadProfile()`。
|
||||||
|
|||||||
254
suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift
Normal file
254
suixinkan/Features/ProfileSpace/Models/ProfileSpaceModels.swift
Normal file
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
58
suixinkan/Features/ProfileSpace/ProfileSpace.md
Normal file
58
suixinkan/Features/ProfileSpace/ProfileSpace.md
Normal file
@ -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`,成功后重新加载空间配置,保持日程来源一致。
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
733
suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift
Normal file
733
suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift
Normal file
@ -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<Content: View>(@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<String>,
|
||||||
|
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<Date?>, end: Binding<Date?>) -> 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<Date?>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
130
suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift
Normal file
130
suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift
Normal file
@ -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<TravelAlbumItem>
|
||||||
|
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<TravelAlbumMaterial>
|
||||||
|
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<TravelAlbumItem> {
|
||||||
|
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<TravelAlbumMaterial> {
|
||||||
|
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)")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
453
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
453
suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
Normal file
@ -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<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumSession.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/29.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 旅拍相册跨页会话上下文,保存当前操作的相册任务。
|
||||||
|
enum TravelAlbumSession {
|
||||||
|
static var currentAlbum: TravelAlbumItem?
|
||||||
|
}
|
||||||
@ -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<String> {
|
||||||
|
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<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
suixinkan/Features/TravelAlbum/TravelAlbum.md
Normal file
38
suixinkan/Features/TravelAlbum/TravelAlbum.md
Normal file
@ -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 上传
|
||||||
@ -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<String> = []
|
||||||
|
@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<String> = []
|
||||||
|
|
||||||
|
/// 初始化有线传图 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
|
||||||
|
}()
|
||||||
|
}
|
||||||
@ -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<String>, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
156
suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift
Normal file
156
suixinkan/Features/TravelAlbum/Views/TravelAlbumDetailView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
207
suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift
Normal file
207
suixinkan/Features/TravelAlbum/Views/TravelAlbumEntryView.swift
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,15 +41,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<dict>
|
<string>Launch Screen</string>
|
||||||
<key>UIColorName</key>
|
|
||||||
<string>LaunchBackground</string>
|
|
||||||
<key>UIImageName</key>
|
|
||||||
<string>SplashLogo</string>
|
|
||||||
<key>UIImageRespectsSafeAreaInsets</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
27
suixinkan/Launch Screen.storyboard
Normal file
27
suixinkan/Launch Screen.storyboard
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24765" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24743"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
|
||||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
63
suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift
Normal file
63
suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -65,6 +65,7 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
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: "operating-area", title: ""), .destination(.operatingArea))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification))
|
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: "queue_management", title: ""), .destination(.queueManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "travel_album", title: ""), .destination(.travelAlbumEntry))
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
351
suixinkanTests/ProfileSpace/ProfileSpaceTests.swift
Normal file
351
suixinkanTests/ProfileSpace/ProfileSpaceTests.swift
Normal file
@ -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<ProfileSpaceInfoResponse>.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 { "" }
|
||||||
|
}
|
||||||
100
suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift
Normal file
100
suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift
Normal file
@ -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])
|
||||||
|
}
|
||||||
124
suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift
Normal file
124
suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift
Normal file
@ -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<TravelAlbumItem> {
|
||||||
|
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<TravelAlbumMaterial> {
|
||||||
|
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: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,7 +26,7 @@ enum UITestHomeMenuCatalog {
|
|||||||
|
|
||||||
/// 去重后的首页路由覆盖表,同义 URI 只保留一个菜单标题。
|
/// 去重后的首页路由覆盖表,同义 URI 只保留一个菜单标题。
|
||||||
static let entries: [Entry] = [
|
static let entries: [Entry] = [
|
||||||
Entry(menuTitle: "空间设置", expectation: .navigationTitle("个人信息")),
|
Entry(menuTitle: "空间设置", expectation: .navigationTitle("个人空间配置")),
|
||||||
Entry(menuTitle: "相册管理", expectation: .navigationTitle("相册管理")),
|
Entry(menuTitle: "相册管理", expectation: .navigationTitle("相册管理")),
|
||||||
Entry(menuTitle: "相册预览上传", expectation: .navigationTitle("相册预览上传")),
|
Entry(menuTitle: "相册预览上传", expectation: .navigationTitle("相册预览上传")),
|
||||||
Entry(menuTitle: "我的钱包", expectation: .navigationTitle("我的钱包")),
|
Entry(menuTitle: "我的钱包", expectation: .navigationTitle("我的钱包")),
|
||||||
|
|||||||
@ -86,6 +86,7 @@
|
|||||||
|
|
||||||
| 状态 | 模块 | 当前情况 | 后续事项 |
|
| 状态 | 模块 | 当前情况 | 后续事项 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| 完成 | 空间设置 | `space_settings` 已接真实“个人空间配置”页面;支持资料/头像/简介、标签、拍摄说明、营业时间、相机设备、接单状态和未来 7 天日程展示;日程新增/删除复用 Schedule 模块。 | 相册管理继续由相册模块承接,不在空间设置页重复迁移。 |
|
||||||
| 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 |
|
| 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 |
|
||||||
| 完成 | 提现审核 | 首页入口已接真实页面,复用提现记录接口展示审核列表、状态筛选、分页和详情时间线。 | 不做管理员审批操作,旧 Android 未体现对应操作接口。 |
|
| 完成 | 提现审核 | 首页入口已接真实页面,复用提现记录接口展示审核列表、状态筛选、分页和详情时间线。 | 不做管理员审批操作,旧 Android 未体现对应操作接口。 |
|
||||||
| 完成 | 消息中心 | 消息列表、未读筛选、游标分页、已读/全部已读、详情和删除已接入。 | 本轮不新增后端未体现的批量接口。 |
|
| 完成 | 消息中心 | 消息列表、未读筛选、游标分页、已读/全部已读、详情和删除已接入。 | 本轮不新增后端未体现的批量接口。 |
|
||||||
@ -101,6 +102,7 @@
|
|||||||
| 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 |
|
| 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 |
|
||||||
| 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 |
|
| 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 |
|
||||||
| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 |
|
| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 |
|
||||||
|
| 完成 | 旅拍相册 | `travel_album` 已接真实页面;支持新建相册任务、相册详情、小程序码、Sony USB 有线传图、OSS 上传与 upload-material 登记。 | RAW 预览、设备存储弹窗等 Android 细节可二期补齐;需真机 + Sony 相机联调 OTG。 |
|
||||||
| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 |
|
| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 |
|
||||||
| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 |
|
| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 |
|
||||||
| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制外部推流地址、开始/暂停/结束、推流模式切换、直播详情可播放 URL 预览、直播相册视频播放,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 已按旧 iOS 对齐:不新增真推流 SDK,`push_url` 仅作为 OBS/第三方工具外部推流地址;页面保留推流诊断适配层,真 RTMP/RTC 推流 SDK 仍需后续单独选型和真机联调。 |
|
| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制外部推流地址、开始/暂停/结束、推流模式切换、直播详情可播放 URL 预览、直播相册视频播放,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 已按旧 iOS 对齐:不新增真推流 SDK,`push_url` 仅作为 OBS/第三方工具外部推流地址;页面保留推流诊断适配层,真 RTMP/RTC 推流 SDK 仍需后续单独选型和真机联调。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user