Add TravelAlbum, ProfileSpace, and wired camera transfer modules.
Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user