Compare commits

..

2 Commits

Author SHA1 Message Date
258c438f9a 优化冷启动流程:Launch Screen 对齐 Splash,仅阻塞 rolePermissions。
移除 SplashCoordinator,bootstrap 期间用 SplashView 覆盖避免空白闪屏,其余账号数据改为后台补充刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 09:56:49 +08:00
d2fe5d71e4 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>
2026-06-30 09:41:05 +08:00
62 changed files with 7022 additions and 234 deletions

View File

@ -10,13 +10,13 @@
App 启动链路为: App 启动链路为:
**`suixinkanApp``RootView``SplashView` 覆盖层 + 并行 bootstrap → 按 `AppSession.phase` 展示 `LoginView``MainTabsView`** **`suixinkanApp``RootView`阻塞 `rolePermissions` + 后台补充 bootstrap → 按 `AppSession.phase` 展示 `LoginView``MainTabsView`**
用户感知的「启动 Loading」并非 Lottie 全局 Loading而是 用户感知的「启动 Loading」并非 Lottie 全局 Loading而是
1. **系统 Launch Screen**白底 + LogoiOS 原生) 1. **系统 Launch Screen**品牌页iOS 原生)
2. **应用内 `SplashView`**品牌页bootstrap 完成即结束,无固定最短展示时长 2. **`.restoring` 短暂空白**(仅等待 `rolePermissions`,完成后立即进主界面
3. **有 token 时的 `.restoring` 阶段**Splash 下方空白背景,等待 4 个 API 完成 3. **后台补充刷新**`userInfo``scenicListAll``storeAll`,不阻塞首屏
冷启动期间 **不使用** `GlobalLoadingCenter` / Lottie 动画。 冷启动期间 **不使用** `GlobalLoadingCenter` / Lottie 动画。

View File

@ -30,27 +30,26 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
1. `suixinkanApp` 创建 `RootView` 1. `suixinkanApp` 创建 `RootView`
2. `RootView` 初始化共享依赖,并把它们注入 Environment。 2. `RootView` 初始化共享依赖,并把它们注入 Environment。
3. iOS 系统 Launch Screen 展示白底 + `SplashLogo` 3. iOS 系统 Launch Screen 展示品牌页白底、Logo、文案
4. `SplashView` 覆盖根视图,展示与 Android 对齐的品牌启动页 4. `RootView` 挂载后在 bootstrap 完成前以 `SplashView` 覆盖根视图,避免空白或登录页闪屏
5. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore`Splash 以 bootstrap 完成为准,无固定最短展示时长 5. `APIClient` 绑定 `AppSession.token` 作为默认 token provider
6. `APIClient` 绑定 `AppSession.token` 作为默认 token provider 6. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token
7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token 7. 无 token 时保持 `loggedOut`,展示 `LoginView`
8. token 时保持 `loggedOut`Splash 结束后展示 `LoginView` 8. token 时进入 `restoring`,先恢复本地账号快照
9. 有 token 时进入 `restoring`,先恢复本地账号快照 9. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`
10. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店 10. `userInfo``scenicListAll``storeAll` 在后台补充刷新,不阻塞首屏
11. 校验成功后进入 `loggedIn`Splash 结束后展示 `MainTabsView` 11. `ScenicSpotContext` 按当前景区懒加载景点/打卡点
12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点 12. 明确 token 失效时清空 token 和账号快照,回到登录页
13. 明确 token 失效时清空 token 和账号快照,回到登录页 13. 普通网络失败时保留本地登录态,使用账号快照进入主界面
14. 普通网络失败时保留本地登录态,使用账号快照进入主界面 14. 冷启动 bootstrap 不使用 Lottie 全局 Loading`.restoring` 期间展示 `SplashView`
15. 冷启动 bootstrap 不使用 Lottie 全局 LoadingSplash 即启动等待层 15. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默
16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。
## 初始化分层 ## 初始化分层
| 阶段 | 职责 | 主要对象 | | 阶段 | 职责 | 主要对象 |
|------|------|----------| |------|------|----------|
| App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp``AppUITestLaunchState` | | App 入口 | UI Test 状态清理、后续可扩展 SDK 初始化 | `suixinkanApp``AppUITestLaunchState` |
| Splash | 品牌展示 + 登录态恢复 | `SplashView``SplashCoordinator``SessionBootstrapper` | | 冷启动恢复 | Launch Screen + Splash 覆盖 bootstrap | `SplashView``SessionBootstrapper``AccountContextLoader` |
| 首屏出现后 | 页面级远程配置 | `LoginView``AppConfigAPI` | | 首屏出现后 | 页面级远程配置 | `LoginView``AppConfigAPI` |
| 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager``ScenicSpotContext` | | 登录后 | 推送、景点列表、排队 WebSocket | `PushNotificationManager``ScenicSpotContext` |

View File

@ -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 }

View File

@ -48,7 +48,7 @@ struct RootView: View {
@State private var cooperationOrderAPI: CooperationOrderAPI @State private var cooperationOrderAPI: CooperationOrderAPI
@State private var authSessionCoordinator: AuthSessionCoordinator @State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper @State private var sessionBootstrapper: SessionBootstrapper
@StateObject private var splashCoordinator = SplashCoordinator() @State private var isColdStartBootstrapPending = true
@State private var appConfigAPI: AppConfigAPI @State private var appConfigAPI: AppConfigAPI
/// API token provider /// API token provider
@ -106,7 +106,7 @@ struct RootView: View {
ZStack { ZStack {
rootContent rootContent
if !splashCoordinator.isFinished { if isColdStartBootstrapPending {
SplashView() SplashView()
.transition(.opacity) .transition(.opacity)
.zIndex(1) .zIndex(1)
@ -155,15 +155,16 @@ struct RootView: View {
.task { .task {
apiClient.bindAuthTokenProvider { appSession.token } apiClient.bindAuthTokenProvider { appSession.token }
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter) PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
await splashCoordinator.start { await sessionBootstrapper.restore(
await sessionBootstrapper.restore( appSession: appSession,
appSession: appSession, accountContext: accountContext,
accountContext: accountContext, permissionContext: permissionContext,
permissionContext: permissionContext, profileAPI: profileAPI,
profileAPI: profileAPI, accountContextAPI: accountContextAPI,
accountContextAPI: accountContextAPI, toastCenter: toastCenter
toastCenter: toastCenter )
) withAnimation {
isColdStartBootstrapPending = false
} }
if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests { if appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
PushNotificationManager.shared.requestAuthorizationAndRegister() PushNotificationManager.shared.requestAuthorizationAndRegister()
@ -227,7 +228,7 @@ struct RootView: View {
case .loggedOut: case .loggedOut:
LoginView() LoginView()
case .restoring: case .restoring:
Color(.systemBackground) Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
case .loggedIn: case .loggedIn:
MainTabsView() MainTabsView()

View File

@ -1,45 +0,0 @@
//
// SplashCoordinator.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Combine
import Foundation
@MainActor
/// bootstrapSplash bootstrap
final class SplashCoordinator: ObservableObject {
@Published private(set) var isFinished = false
private let minimumDisplayDuration: TimeInterval
private let skipsMinimumDuration: Bool
/// delay
init(
minimumDisplayDuration: TimeInterval = 0,
skipsMinimumDuration: Bool = AppUITestLaunchState.isRunningUITests
) {
self.minimumDisplayDuration = minimumDisplayDuration
self.skipsMinimumDuration = skipsMinimumDuration
}
/// bootstrap Splash
func start(bootstrap: @escaping () async -> Void) async {
guard !isFinished else { return }
async let minimumDelay: Void = waitForMinimumDisplayDuration()
async let bootstrapWork: Void = bootstrap()
_ = await (minimumDelay, bootstrapWork)
isFinished = true
}
/// UI Test
private func waitForMinimumDisplayDuration() async {
guard !skipsMinimumDuration else { return }
let nanoseconds = UInt64(max(minimumDisplayDuration, 0) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
}
}

View File

@ -17,7 +17,7 @@ protocol UserProfileServing {
@MainActor @MainActor
/// ///
struct AccountContextLoader { struct AccountContextLoader {
/// ///
func refresh( func refresh(
accountContext: AccountContext, accountContext: AccountContext,
permissionContext: PermissionContext, permissionContext: PermissionContext,
@ -28,17 +28,66 @@ struct AccountContextLoader {
cachedCurrentScenicId: Int? = nil, cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil cachedCurrentStoreId: Int? = nil
) async throws { ) async throws {
async let userData = profileAPI.userInfo() try await restorePermissions(
async let roleData = accountContextAPI.rolePermissions() permissionContext: permissionContext,
let (userInfo, rolePermissions) = try await (userData, roleData) accountContext: accountContext,
accountContextAPI: accountContextAPI,
cachedCurrentRoleCode: cachedCurrentRoleCode,
cachedLegacyRoleId: cachedLegacyRoleId,
cachedCurrentScenicId: cachedCurrentScenicId,
cachedCurrentStoreId: cachedCurrentStoreId
)
try await refreshSupplementalContext(
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
cachedCurrentScenicId: cachedCurrentScenicId,
cachedCurrentStoreId: cachedCurrentStoreId
)
}
///
func restorePermissions(
permissionContext: PermissionContext,
accountContext: AccountContext,
accountContextAPI: AccountContextServing,
cachedCurrentRoleCode: String? = nil,
cachedLegacyRoleId: Int? = nil,
cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil
) async throws {
let rolePermissions = try await accountContextAPI.rolePermissions()
permissionContext.replaceRolePermissions( permissionContext.replaceRolePermissions(
rolePermissions, rolePermissions,
currentRoleCode: cachedCurrentRoleCode, currentRoleCode: cachedCurrentRoleCode,
cachedLegacyRoleId: cachedLegacyRoleId cachedLegacyRoleId: cachedLegacyRoleId
) )
let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions) let roleScenicScopes = permissionContext.currentRoleScenicScopes()
guard accountContext.scenicScopes.isEmpty, !roleScenicScopes.isEmpty else { return }
accountContext.replaceScopes(
scenic: roleScenicScopes,
stores: accountContext.storeScopes,
currentScenicId: cachedCurrentScenicId,
currentStoreId: cachedCurrentStoreId
)
}
///
func refreshSupplementalContext(
accountContext: AccountContext,
permissionContext: PermissionContext,
profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing,
cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil
) async throws {
let userInfo = try await profileAPI.userInfo()
let scenicResponse = await loadScenicList(
accountContextAPI: accountContextAPI,
rolePermissions: permissionContext.rolePermissions
)
let storesResponse = await loadStores(accountContextAPI: accountContextAPI) let storesResponse = await loadStores(accountContextAPI: accountContextAPI)
let profile = makeProfile(from: userInfo, fallback: accountContext.profile) let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
let scenicScopes = scopedScenics( let scenicScopes = scopedScenics(
@ -53,8 +102,8 @@ struct AccountContextLoader {
accountContext.replaceScopes( accountContext.replaceScopes(
scenic: scenicScopes, scenic: scenicScopes,
stores: storeScopes, stores: storeScopes,
currentScenicId: cachedCurrentScenicId, currentScenicId: cachedCurrentScenicId ?? accountContext.currentScenic?.id,
currentStoreId: cachedCurrentStoreId currentStoreId: cachedCurrentStoreId ?? accountContext.currentStore?.id
) )
} }

View File

@ -32,7 +32,7 @@ final class SessionBootstrapper {
) )
} }
/// /// rolePermissions
func restore( func restore(
appSession: AppSession, appSession: AppSession,
accountContext: AccountContext, accountContext: AccountContext,
@ -51,32 +51,38 @@ final class SessionBootstrapper {
appSession.beginRestoring(token: token) appSession.beginRestoring(token: token)
let cachedSnapshot = restoreCachedSnapshot(to: accountContext) let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
let loader = AccountContextLoader()
do { do {
try await AccountContextLoader().refresh( try await loader.restorePermissions(
accountContext: accountContext,
permissionContext: permissionContext, permissionContext: permissionContext,
profileAPI: profileAPI, accountContext: accountContext,
accountContextAPI: accountContextAPI, accountContextAPI: accountContextAPI,
cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode, cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode,
cachedLegacyRoleId: cachedSnapshot?.currentRoleId, cachedLegacyRoleId: cachedSnapshot?.currentRoleId,
cachedCurrentScenicId: cachedSnapshot?.currentScenicId, cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
cachedCurrentStoreId: cachedSnapshot?.currentStoreId cachedCurrentStoreId: cachedSnapshot?.currentStoreId
) )
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
appSession.markLoggedIn(token: token) appSession.markLoggedIn(token: token)
refreshSupplementalContextInBackground(
loader: loader,
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
toastCenter: toastCenter,
cachedSnapshot: cachedSnapshot
)
} catch { } catch {
if APIError.isAuthenticationExpired(error) { handleBlockingRestoreFailure(
try? tokenStore.clear() error,
snapshotStore.clear() token: token,
accountContext.reset() appSession: appSession,
permissionContext.reset() accountContext: accountContext,
appSession.logout() permissionContext: permissionContext,
toastCenter.show("登录状态已失效,请重新登录") toastCenter: toastCenter
} else { )
appSession.markLoggedIn(token: token)
toastCenter.show("网络异常,已使用本地登录状态")
}
} }
} }
@ -93,6 +99,97 @@ final class SessionBootstrapper {
return snapshot return snapshot
} }
///
private func refreshSupplementalContextInBackground(
loader: AccountContextLoader,
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing,
toastCenter: ToastCenter,
cachedSnapshot: AccountSnapshot?
) {
Task {
do {
try await loader.refreshSupplementalContext(
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
)
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
} catch {
handleSupplementalRefreshFailure(
error,
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
toastCenter: toastCenter
)
}
}
}
///
private func handleBlockingRestoreFailure(
_ error: Error,
token: String,
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
toastCenter: ToastCenter
) {
if APIError.isAuthenticationExpired(error) {
clearPersistedSession(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext
)
toastCenter.show("登录状态已失效,请重新登录")
return
}
appSession.markLoggedIn(token: token)
toastCenter.show("网络异常,已使用本地登录状态")
}
///
private func handleSupplementalRefreshFailure(
_ error: Error,
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext,
toastCenter: ToastCenter
) {
if APIError.isAuthenticationExpired(error) {
clearPersistedSession(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext
)
toastCenter.show("登录状态已失效,请重新登录")
return
}
toastCenter.show("网络异常,已使用本地登录状态")
}
/// token
private func clearPersistedSession(
appSession: AppSession,
accountContext: AccountContext,
permissionContext: PermissionContext
) {
try? tokenStore.clear()
snapshotStore.clear()
accountContext.reset()
permissionContext.reset()
appSession.logout()
}
/// ///
private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) { private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) {
let existing = snapshotStore.load() let existing = snapshotStore.load()
@ -109,5 +206,4 @@ final class SessionBootstrapper {
) )
) )
} }
} }

View 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系统框架
- 无第三方库依赖

View File

@ -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) 相机尚未实现")
}
}
}

View 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
}
}

View 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"
}
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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)
}
}

View 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)
}
}

View 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)
}
}

View File

@ -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
}
}

View File

@ -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)")
}
}

View 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
}

View File

@ -0,0 +1,25 @@
# CameraTransfer
## 职责
编排相机照片从下载到上传的通用流程,通过 `CameraAssetUploadSink` 与业务层解耦。
## 核心组件
| 组件 | 说明 |
| --- | --- |
| `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 |
| `CameraTransferTask` | 单条传输任务及状态 |
| `CameraAssetUploadSink` | 上传协议,由业务模块实现 |
## 业务流程
1. 相机服务回调 `onNewAsset`
2. Pipeline 下载文件到本地并更新任务状态。
3. 若开启自动上传,调用 `CameraAssetUploadSink.upload`
4. 上传成功后任务状态变为 `uploaded`
## 与业务层关系
- 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 SinkOSS 上传 + `upload-material` 登记。
- Pipeline 不依赖任何 HTTP 或 OSS 具体实现。

View 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)
}
}

View 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)
}
}

View File

@ -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)

View File

@ -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",

View File

@ -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":

View File

@ -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:

View File

@ -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 {}

View File

@ -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()`

View 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 ""
}
/// StringDouble 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 []
}
}

View 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`,成功后重新加载空间配置,保持日程来源一致。

View File

@ -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
}
}
}
}

View 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
}
}

View File

@ -2,32 +2,36 @@
## 模块职责 ## 模块职责
Splash 模块负责冷启动时的品牌启动页展示,对齐 Android `SplashActivity` + `activity_splash.xml` 的视觉规范 Splash 模块负责冷启动品牌展示,与系统 Launch Screen 保持相同视觉,并在 bootstrap 完成前避免空白页或登录页闪屏
该模块不处理登录、隐私协议或远程配置,只承担启动阶段的品牌展示和时序控制 冷启动恢复登录态由 `SessionBootstrapper` 负责;`SplashView` 仅覆盖 bootstrap 阻塞阶段,不做固定时长品牌停留
## 核心对象 ## 核心对象
- `SplashView`展示白底、180pt Logo、「随心瞰 / 商家版」文案 - `SplashView`冷启动 bootstrap 期间展示的品牌页,与 Launch Screen 布局一致
- `SplashCoordinator`:并行执行 `SessionBootstrapper.restore`bootstrap 完成后结束 Splash无固定最短展示时长 - `SplashTypography`:启动页标题/副标题字号与 UIKit 字体,供 `SplashView``Launch Screen.storyboard` 对齐
- `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源,同时用于系统 `UILaunchScreen` - `SplashLogo` / `LaunchBackground`:启动页图片与背景色资源。
- `SessionBootstrapper`:冷启动恢复登录态,阻塞等待 `rolePermissions`,其余数据后台刷新。
- `AccountContextLoader.restorePermissions` / `refreshSupplementalContext`:拆分阻塞阶段与后台补充阶段。
## 展示规则 ## 展示规则
- 背景色:白色 `#FFFFFF` - 系统 Launch Screen白底、180×180 Logo、「随心瞰 / 商家版」文案。
- Logo`SplashLogo`180×180 pt - 应用内 `SplashView`bootstrap 完成前覆盖根视图,避免 `.restoring` 空白或登录页闪现。
- 标题:`随心瞰`40ptbold + italic`AppDesign.primary` - bootstrap 结束后淡出 Splash进入 `LoginView``MainTabsView`
- 副标题:`商家版`20ptbold距底 32pt - 无固定最短展示时长。
## 启动时序 ## 启动时序
1. iOS 系统 Launch Screen 展示白底 + Logo 1. iOS 系统 Launch Screen 展示品牌页
2. `RootView` 挂载后立即显示 `SplashView` 2. `RootView` 挂载后立即显示 `SplashView` 覆盖层
3. `SplashCoordinator.start` 并行执行 `SessionBootstrapper.restore` 静默恢复登录态。 3. `SessionBootstrapper.restore` 并行恢复登录态。
4. bootstrap 完成后淡出 Splash进入 `LoginView``MainTabsView` 4. 无 tokenbootstrap 完成后进入 `LoginView`
5. 冷启动期间不显示 Lottie 全局 Loading 5. 有 token恢复 `AccountSnapshot` → 阻塞请求 `rolePermissions``markLoggedIn` → 进入 `MainTabsView`
6. `userInfo``scenicListAll``storeAll` 在后台补充刷新,不阻塞首屏。
7. 冷启动期间不显示 Lottie 全局 Loading。
## 与 Android 的差异 ## 与 Android 的差异
- Android 首启隐私弹窗在 Splash 上iOS 仍在登录页勾选协议。 - Android 首启隐私弹窗在 Splash 上iOS 仍在登录页勾选协议。
- Android 系统 Launch Screen 与应用内 Splash 使用同一 XMLiOS 系统 Launch Screen 仅展示 Logo文案由 SwiftUI Splash 补齐 - Android 系统 Launch Screen 与应用内 Splash 使用同一 XMLiOS 系统 Launch Screen 与应用内 `SplashView` 保持相同布局,但应用内 Splash 仅在 bootstrap 期间展示

View File

@ -0,0 +1,37 @@
//
// SplashTypography.swift
// suixinkan
//
// Created by Codex on 2026/6/30.
//
import SwiftUI
import UIKit
/// `SplashView` `Launch Screen.storyboard`
enum SplashTypography {
///
static let titlePointSize: CGFloat = 40
///
static let subtitlePointSize: CGFloat = 20
/// UIKit 40pt system bold + italic
static var titleUIFont: UIFont {
let base = UIFont.systemFont(ofSize: titlePointSize, weight: .bold)
guard let descriptor = base.fontDescriptor.withSymbolicTraits([.traitBold, .traitItalic]) else {
return base
}
return UIFont(descriptor: descriptor, size: titlePointSize)
}
/// UIKit 20pt system bold
static var subtitleUIFont: UIFont {
UIFont.systemFont(ofSize: subtitlePointSize, weight: .bold)
}
/// SwiftUI
static var titleFont: Font { Font(titleUIFont) }
/// SwiftUI
static var subtitleFont: Font { Font(subtitleUIFont) }
}

View File

@ -7,7 +7,7 @@
import SwiftUI import SwiftUI
/// Android `activity_splash.xml` /// Launch Screen
struct SplashView: View { struct SplashView: View {
var body: some View { var body: some View {
ZStack { ZStack {
@ -24,15 +24,14 @@ struct SplashView: View {
.accessibilityIdentifier("splash.logo") .accessibilityIdentifier("splash.logo")
Text("随心瞰") Text("随心瞰")
.font(.system(size: 40, weight: .bold)) .font(SplashTypography.titleFont)
.italic()
.foregroundStyle(AppDesign.primary) .foregroundStyle(AppDesign.primary)
.accessibilityIdentifier("splash.title") .accessibilityIdentifier("splash.title")
Spacer() Spacer()
Text("商家版") Text("商家版")
.font(.system(size: 20, weight: .bold)) .font(SplashTypography.subtitleFont)
.foregroundStyle(AppDesign.primary) .foregroundStyle(AppDesign.primary)
.padding(.bottom, 32) .padding(.bottom, 32)
.accessibilityIdentifier("splash.subtitle") .accessibilityIdentifier("splash.subtitle")

View 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)")]
)
)
}
}

View 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
}
}

View File

@ -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
/// APIOSS
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
}
}

View File

@ -0,0 +1,13 @@
//
// TravelAlbumSession.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
///
enum TravelAlbumSession {
static var currentAlbum: TravelAlbumItem?
}

View File

@ -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
}
}

View 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 上传

View File

@ -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
}()
}

View File

@ -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))
}
}
}

View File

@ -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])
}
}

View 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()
}
}
}

View 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())
}
}

View File

@ -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)
}
}

View File

@ -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>

View File

@ -0,0 +1,72 @@
<?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"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" layoutMarginsRelativeArrangement="YES" axis="vertical" alignment="center" spacing="0" translatesAutoresizingMaskIntoConstraints="NO" id="Stk-Mn-001">
<rect key="frame" x="0.0" y="59" width="393" height="759"/>
<subviews>
<view contentMode="scaleToFill" verticalHuggingPriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="Spc-Tp-001">
<rect key="frame" x="0.0" y="0.0" width="393" height="200.5"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="SplashLogo" translatesAutoresizingMaskIntoConstraints="NO" id="Img-Lg-001">
<rect key="frame" x="106.5" y="200.5" width="180" height="180"/>
<constraints>
<constraint firstAttribute="width" constant="180" id="Img-Wd-001"/>
<constraint firstAttribute="height" constant="180" id="Img-Ht-001"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="随心瞰" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lbl-Tt-001">
<rect key="frame" x="136.66666666666666" y="380.5" width="119.66666666666666" height="48"/>
<fontDescription key="fontDescription" type="system" weight="bold" pointSize="40"/>
<color key="textColor" red="0.0" green="0.45098039215686275" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" verticalHuggingPriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="Spc-Bt-001">
<rect key="frame" x="0.0" y="428.5" width="393" height="200.5"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="商家版" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Lbl-St-001">
<rect key="frame" x="161.66666666666666" y="629" width="69.666666666666686" height="24"/>
<fontDescription key="fontDescription" type="system" weight="bold" pointSize="20"/>
<color key="textColor" red="0.0" green="0.45098039215686275" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="32" right="0.0"/>
</stackView>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="Stk-Mn-001" firstAttribute="top" secondItem="Bcu-3y-fUS" secondAttribute="top" id="Stk-Tp-001"/>
<constraint firstItem="Stk-Mn-001" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="Stk-Ld-001"/>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="trailing" secondItem="Stk-Mn-001" secondAttribute="trailing" id="Stk-Tr-001"/>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="Stk-Mn-001" secondAttribute="bottom" id="Stk-Bt-001"/>
<constraint firstItem="Spc-Tp-001" firstAttribute="height" secondItem="Spc-Bt-001" secondAttribute="height" id="Spc-Eq-001"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="SplashLogo" width="180" height="180"/>
</resources>
</document>

View File

@ -65,6 +65,64 @@ final class AccountContextLoaderTests: XCTestCase {
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88]) XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
} }
/// PermissionContext
func testRestorePermissionsLoadsRolePermissionsOnly() async throws {
let accountContext = AccountContext()
let permissionContext = PermissionContext()
let api = MockAccountContextAPI()
api.roles = [
RolePermissionResponse(
role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
)
]
try await AccountContextLoader().restorePermissions(
permissionContext: permissionContext,
accountContext: accountContext,
accountContextAPI: api
)
XCTAssertEqual(permissionContext.currentRole?.id, 7)
XCTAssertTrue(permissionContext.canAccess("/home"))
XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88])
XCTAssertNil(accountContext.profile)
}
///
func testRefreshSupplementalContextUpdatesProfileAndScopes() async throws {
let accountContext = AccountContext()
let permissionContext = PermissionContext()
let api = MockAccountContextAPI()
api.roles = [
RolePermissionResponse(
role: RoleInfo(id: 7, name: "运营"),
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
)
]
api.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")])
api.storeResponse = ListPayload(total: 1, list: [StoreItem(id: 66, scenicId: 88, name: "东湖门店")])
try await AccountContextLoader().restorePermissions(
permissionContext: permissionContext,
accountContext: accountContext,
accountContextAPI: api
)
try await AccountContextLoader().refreshSupplementalContext(
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: MockUserProfileAPI(),
accountContextAPI: api,
cachedCurrentScenicId: 88,
cachedCurrentStoreId: 66
)
XCTAssertEqual(accountContext.profile?.displayName, "测试用户")
XCTAssertEqual(accountContext.currentScenic?.id, 88)
XCTAssertEqual(accountContext.currentStore?.id, 66)
}
/// ///
func testRefreshAllowsStoreFailure() async throws { func testRefreshAllowsStoreFailure() async throws {
let accountContext = AccountContext() let accountContext = AccountContext()

View File

@ -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)"
}
}

View 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)
}
}

View File

@ -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

View File

@ -0,0 +1,351 @@
//
// ProfileSpaceTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// APIViewModel
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 { "" }
}

View File

@ -0,0 +1,144 @@
//
// SessionBootstrapperTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/30.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class SessionBootstrapperTests: XCTestCase {
/// token
func testRestoreWithoutTokenLogsOut() async {
let defaults = makeDefaults()
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
let appSession = AppSession()
let accountContext = AccountContext()
let permissionContext = PermissionContext()
let toastCenter = ToastCenter()
await bootstrapper.restore(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: MockUserProfileAPI(),
accountContextAPI: MockAccountContextAPI(),
toastCenter: toastCenter
)
XCTAssertEqual(appSession.phase, .loggedOut)
}
/// token rolePermissions loggedIn
func testRestoreMarksLoggedInAfterRolePermissions() async {
let defaults = makeDefaults()
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
try? tokenStore.save("token-1")
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
let appSession = AppSession()
let accountContext = AccountContext()
let permissionContext = PermissionContext()
let toastCenter = ToastCenter()
let api = MockAccountContextAPI()
api.roles = [
RolePermissionResponse(
role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]),
scenic: [ScenicInfo(id: 88, name: "东湖景区")]
)
]
await bootstrapper.restore(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: MockUserProfileAPI(),
accountContextAPI: api,
toastCenter: toastCenter
)
XCTAssertEqual(appSession.phase, .loggedIn)
XCTAssertEqual(permissionContext.currentRole?.id, 7)
XCTAssertTrue(permissionContext.canAccess("/home"))
let profileExpectation = expectation(description: "profile loaded in background")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
if accountContext.profile?.displayName == "测试用户" {
profileExpectation.fulfill()
}
}
await fulfillment(of: [profileExpectation], timeout: 2)
}
/// rolePermissions
func testRestoreLogsOutWhenRolePermissionsExpired() async {
let defaults = makeDefaults()
let tokenStore = SessionTokenStore(defaults: defaults, key: "test.token")
try? tokenStore.save("token-1")
let snapshotStore = AccountSnapshotStore(defaults: defaults, key: "test.snapshot")
snapshotStore.save(AccountSnapshot(profile: AccountProfile(userId: "1", displayName: "本地用户")))
let bootstrapper = SessionBootstrapper(tokenStore: tokenStore, snapshotStore: snapshotStore)
let appSession = AppSession()
let accountContext = AccountContext()
let permissionContext = PermissionContext()
let toastCenter = ToastCenter()
let api = MockAccountContextAPI()
api.roleError = APIError.httpStatus(401, "登录状态已失效")
await bootstrapper.restore(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: MockUserProfileAPI(),
accountContextAPI: api,
toastCenter: toastCenter
)
XCTAssertEqual(appSession.phase, .loggedOut)
XCTAssertNil(tokenStore.load())
XCTAssertNil(snapshotStore.load())
}
private func makeDefaults() -> UserDefaults {
let suiteName = "SessionBootstrapperTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
}
@MainActor
private final class MockUserProfileAPI: UserProfileServing {
func userInfo() async throws -> UserInfoResponse {
try await Task.sleep(nanoseconds: 100_000_000)
return UserInfoResponse(nickname: "测试用户", roleName: "运营")
}
}
@MainActor
private final class MockAccountContextAPI: AccountContextServing {
var roles: [RolePermissionResponse] = []
var roleError: Error?
func rolePermissions() async throws -> [RolePermissionResponse] {
if let roleError { throw roleError }
return roles
}
func scenicListAll() async throws -> ScenicListAllResponse {
ScenicListAllResponse(total: 0, list: [])
}
func storeAll() async throws -> ListPayload<StoreItem> {
ListPayload(total: 0, list: [])
}
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
ListPayload(total: 0, list: [])
}
}

View File

@ -1,81 +0,0 @@
//
// SplashCoordinatorTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// bootstrap UI Test
final class SplashCoordinatorTests: XCTestCase {
/// bootstrap Splash
func testStartFinishesImmediatelyWithDefaultMinimumDuration() async {
let coordinator = SplashCoordinator(skipsMinimumDuration: true)
let startedAt = Date()
await coordinator.start {
try? await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertTrue(coordinator.isFinished)
XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.15)
}
/// bootstrap
func testStartWaitsForMinimumDisplayDuration() async {
let coordinator = SplashCoordinator(minimumDisplayDuration: 0.2, skipsMinimumDuration: false)
let startedAt = Date()
await coordinator.start {
try? await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertTrue(coordinator.isFinished)
XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.19)
}
/// bootstrap bootstrap
func testStartWaitsForSlowBootstrap() async {
let coordinator = SplashCoordinator(minimumDisplayDuration: 0.05, skipsMinimumDuration: false)
let startedAt = Date()
await coordinator.start {
try? await Task.sleep(nanoseconds: 150_000_000)
}
XCTAssertTrue(coordinator.isFinished)
XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(startedAt), 0.14)
}
/// UI Test
func testStartSkipsMinimumDurationWhenRequested() async {
let coordinator = SplashCoordinator(minimumDisplayDuration: 1.0, skipsMinimumDuration: true)
let startedAt = Date()
await coordinator.start {
try? await Task.sleep(nanoseconds: 10_000_000)
}
XCTAssertTrue(coordinator.isFinished)
XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.2)
}
/// start
func testStartIsIdempotentAfterFinished() async {
let coordinator = SplashCoordinator(minimumDisplayDuration: 0, skipsMinimumDuration: true)
var bootstrapCount = 0
await coordinator.start {
bootstrapCount += 1
}
await coordinator.start {
bootstrapCount += 1
}
XCTAssertTrue(coordinator.isFinished)
XCTAssertEqual(bootstrapCount, 1)
}
}

View File

@ -0,0 +1,26 @@
//
// SplashTypographyTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/30.
//
import XCTest
@testable import suixinkan
/// Storyboard SwiftUI 使
final class SplashTypographyTests: XCTestCase {
/// 使 40pt system bold
func testTitleFontUsesExpectedPointSizeAndWeight() {
let font = SplashTypography.titleUIFont
XCTAssertEqual(font.pointSize, SplashTypography.titlePointSize, accuracy: 0.01)
XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold))
}
/// 使 20pt system bold
func testSubtitleFontUsesExpectedPointSizeAndWeight() {
let font = SplashTypography.subtitleUIFont
XCTAssertEqual(font.pointSize, SplashTypography.subtitlePointSize, accuracy: 0.01)
XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold))
}
}

View File

@ -0,0 +1,100 @@
//
// TravelAlbumAPITests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// API pathquery 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])
}

View 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: "")
}
}

View File

@ -43,21 +43,26 @@ final class SuixinkanApp {
hasLaunched = true hasLaunched = true
} }
dismissSystemAlertsIfNeeded() dismissSystemAlertsIfNeeded()
waitForSplashToDisappearIfNeeded() waitForInitialScreen()
if waitForLoading { if waitForLoading {
waitForGlobalLoadingToDisappear() waitForGlobalLoadingToDisappear()
} }
return self return self
} }
/// /// Tab
func waitForSplashToDisappearIfNeeded(timeout: TimeInterval = 5) { func waitForInitialScreen(timeout: TimeInterval = 45) {
let splash = application.otherElements["splash.root"] if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
guard splash.waitForExistence(timeout: 2) else { return } return
}
let predicate = NSPredicate(format: "exists == false") let deadline = Date().addingTimeInterval(timeout)
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: splash) while Date() < deadline {
_ = XCTWaiter.wait(for: [expectation], timeout: timeout) if isOnMainTabs {
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
} }
/// Loading /// Loading
@ -91,19 +96,4 @@ final class SuixinkanApp {
var isOnMainTabs: Bool { var isOnMainTabs: Bool {
TabBarNavigator.isVisible(in: self) TabBarNavigator.isVisible(in: self)
} }
/// Tab
func waitForInitialScreen(timeout: TimeInterval = 45) {
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
return
}
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isOnMainTabs {
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
}
} }

View File

@ -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("我的钱包")),

View File

@ -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 仍需后续单独选型和真机联调。 |