// // ProfileSpaceSettingsViewModel.swift // suixinkan // import Foundation /// 个人空间配置接口协议,便于页面和测试注入真实或模拟 API。 @MainActor protocol ProfileSpaceSettingsAPI { /// 拉取当前景区的个人空间配置。 func profileInfo(scenicId: Int) async throws -> PhotographerProfileInfoResponse /// 更新当前景区的个人空间配置。 func updateProfileInfo(_ request: UpdatePhotographerProfileInfoRequest) async throws /// 更新账号基础昵称或头像。 func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws /// 添加个人日程。 func addSchedule(_ request: AddPhotographerScheduleRequest) async throws /// 删除个人日程。 func deleteSchedule(id: Int) async throws } extension ProfileAPI: ProfileSpaceSettingsAPI {} /// 个人空间配置校验错误。 enum ProfileSpaceSettingsValidationError: LocalizedError, Equatable { case emptyTime(String) case invalidTimeRange(String) case emptyLabel case duplicateLabel case labelTooLong case labelLimitReached case emptyDevice case emptyScheduleName case emptyScheduleTime case invalidScheduleTime var errorDescription: String? { switch self { case .emptyTime(let title): "请输入\(title)" case .invalidTimeRange(let title): "\(title)开始时间不能大于结束时间" case .emptyLabel: "标签不能为空" case .duplicateLabel: "标签已存在" case .labelTooLong: "标签最多20个字符" case .labelLimitReached: "最多8个标签" case .emptyDevice: "设备名称不能为空" case .emptyScheduleName: "请输入日程名称" case .emptyScheduleTime: "请选择日程时间" case .invalidScheduleTime: "结束时间必须晚于开始时间" } } } /// 个人空间配置 ViewModel,对齐 Android `ProfileInfoViewModel` 的状态和校验。 final class ProfileSpaceSettingsViewModel { private(set) var avatarURL = "" private(set) var realName = "" private(set) var nickname = "" private(set) var editingNickname = "" private(set) var introduction = "" private(set) var scenicCertification: [String] = [] private(set) var attrLabels: [String] = [] private(set) var shootLabels: [String] = [] private(set) var businessStartTime: String? private(set) var businessEndTime: String? private(set) var holidayStartTime: String? private(set) var holidayEndTime: String? private(set) var cameraDevices: [String] = [] private(set) var acceptOrderStatus = 0 private(set) var scheduleMap: [String: [PhotographerSchedule]] = [:] private(set) var selectedDate: Date private(set) var isEditingProfile = false private(set) var isLoading = false private(set) var hasChanges = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? private var originalSnapshot = Snapshot() private let appStore: AppStore private let calendar: Calendar private let nowProvider: () -> Date init( appStore: AppStore = .shared, calendar: Calendar = Calendar(identifier: .gregorian), nowProvider: @escaping () -> Date = Date.init ) { self.appStore = appStore self.calendar = calendar self.nowProvider = nowProvider selectedDate = calendar.startOfDay(for: nowProvider()) } /// 当前景区 ID。 var scenicId: Int { appStore.currentScenicId } /// 选中日期对应日程。 var selectedDateSchedules: [PhotographerSchedule] { scheduleMap[Self.dateKey(from: selectedDate, calendar: calendar)] ?? [] } /// 从今天起连续 7 天。 var weekDates: [Date] { let start = calendar.startOfDay(for: nowProvider()) return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: start) } } /// 拉取个人空间配置。 func load(api: any ProfileSpaceSettingsAPI) async { guard scenicId > 0 else { onShowMessage?("请先选择景区") return } setLoading(true) defer { setLoading(false) } do { let response = try await api.profileInfo(scenicId: scenicId) apply(response) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription) } } /// 切换昵称和简介编辑状态,退出编辑时按 Android 逻辑单独保存昵称。 func toggleProfileEditing(api: any ProfileSpaceSettingsAPI) async { if isEditingProfile { let newNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines) let currentNickname = nickname.trimmingCharacters(in: .whitespacesAndNewlines) guard !newNickname.isEmpty, newNickname != currentNickname else { isEditingProfile = false notifyStateChange() return } setLoading(true) defer { setLoading(false) } do { try await api.updateUserInfo(nickname: newNickname, password: nil, avatar: nil) nickname = newNickname appStore.userName = newNickname isEditingProfile = false onShowMessage?("修改成功") notifyStateChange() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription) } return } editingNickname = nickname isEditingProfile = true notifyStateChange() } /// 更新头像 URL。 func updateAvatarURL(_ url: String) { avatarURL = url appStore.avatar = url notifyStateChange() } /// 更新编辑昵称。 func updateEditingNickname(_ value: String) { editingNickname = value notifyStateChange() } /// 更新简介。 func updateIntroduction(_ value: String) { introduction = value updateHasChanges() } /// 添加个人标签。 func addAttrLabel(_ value: String) { do { attrLabels = try adding(label: value, to: attrLabels) updateHasChanges() } catch { onShowMessage?(error.localizedDescription) } } /// 删除个人标签。 func removeAttrLabel(_ label: String) { attrLabels.removeAll { $0 == label } updateHasChanges() } /// 添加拍摄说明标签。 func addShootLabel(_ value: String) { do { shootLabels = try adding(label: value, to: shootLabels) updateHasChanges() } catch { onShowMessage?(error.localizedDescription) } } /// 删除拍摄说明标签。 func removeShootLabel(_ label: String) { shootLabels.removeAll { $0 == label } updateHasChanges() } /// 更新工作日开始时间。 func updateBusinessStartTime(_ value: String) { businessStartTime = value if let end = businessEndTime, !Self.isStart(value, before: end) { onShowMessage?("开始时间不能大于结束时间") businessStartTime = nil } updateHasChanges() } /// 更新工作日结束时间。 func updateBusinessEndTime(_ value: String) { businessEndTime = value if let start = businessStartTime, !Self.isStart(start, before: value) { onShowMessage?("结束时间不能小于开始时间") businessEndTime = nil } updateHasChanges() } /// 更新节假日开始时间。 func updateHolidayStartTime(_ value: String) { holidayStartTime = value if let end = holidayEndTime, !Self.isStart(value, before: end) { onShowMessage?("开始时间不能大于结束时间") holidayStartTime = nil } updateHasChanges() } /// 更新节假日结束时间。 func updateHolidayEndTime(_ value: String) { holidayEndTime = value if let start = holidayStartTime, !Self.isStart(start, before: value) { onShowMessage?("结束时间不能小于开始时间") holidayEndTime = nil } updateHasChanges() } /// 添加相机设备。 func addDevice(_ value: String) { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { onShowMessage?(ProfileSpaceSettingsValidationError.emptyDevice.localizedDescription) return } cameraDevices.append(text) updateHasChanges() } /// 删除相机设备。 func removeDevice(_ value: String) { cameraDevices.removeAll { $0 == value } updateHasChanges() } /// 更新接单状态。 func updateAcceptOrderStatus(_ status: Int) { acceptOrderStatus = status updateHasChanges() } /// 选择日程日期。 func selectDate(_ date: Date) { selectedDate = calendar.startOfDay(for: date) notifyStateChange() } /// 保存个人空间配置。 func save(api: any ProfileSpaceSettingsAPI) async { do { let request = try makeSaveRequest() setLoading(true) defer { setLoading(false) } try await api.updateProfileInfo(request) originalSnapshot = currentSnapshot() hasChanges = false onShowMessage?("保存成功") notifyStateChange() await load(api: api) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription) } } /// 构建个人空间保存请求。 func makeSaveRequest() throws -> UpdatePhotographerProfileInfoRequest { guard let businessStartTime else { throw ProfileSpaceSettingsValidationError.emptyTime("工作日开始时间") } guard let businessEndTime else { throw ProfileSpaceSettingsValidationError.emptyTime("工作日结束时间") } guard let holidayStartTime else { throw ProfileSpaceSettingsValidationError.emptyTime("节假日开始时间") } guard let holidayEndTime else { throw ProfileSpaceSettingsValidationError.emptyTime("节假日结束时间") } guard Self.isStart(businessStartTime, before: businessEndTime) else { throw ProfileSpaceSettingsValidationError.invalidTimeRange("工作日") } guard Self.isStart(holidayStartTime, before: holidayEndTime) else { throw ProfileSpaceSettingsValidationError.invalidTimeRange("节假日") } return UpdatePhotographerProfileInfoRequest( scenicId: String(scenicId), description: introduction, cameraDevice: cameraDevices, attrLabel: attrLabels, shootLabel: shootLabels, businessStartTime: Self.fullTimeString(businessStartTime), businessEndTime: Self.fullTimeString(businessEndTime), holidayStartTime: Self.fullTimeString(holidayStartTime), holidayEndTime: Self.fullTimeString(holidayEndTime), businessRange: [], acceptOrderStatus: acceptOrderStatus ) } /// 构建新增日程请求。 func makeAddScheduleRequest( name: String, remark: String, startTime: String?, endTime: String?, scheduleDate: Date, orderNumber: String? ) throws -> AddPhotographerScheduleRequest { let scheduleName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !scheduleName.isEmpty else { throw ProfileSpaceSettingsValidationError.emptyScheduleName } guard let startTime, let endTime else { throw ProfileSpaceSettingsValidationError.emptyScheduleTime } guard Self.isStart(startTime, before: endTime) else { throw ProfileSpaceSettingsValidationError.invalidScheduleTime } let order = orderNumber?.trimmingCharacters(in: .whitespacesAndNewlines) return AddPhotographerScheduleRequest( scenicId: String(scenicId), name: scheduleName, remark: remark, startTime: startTime, endTime: endTime, scheduleDate: Self.dateKey(from: scheduleDate, calendar: calendar), orderNumber: order?.isEmpty == true ? nil : order ) } /// 添加日程并刷新个人空间配置。 func addSchedule( name: String, remark: String, startTime: String?, endTime: String?, scheduleDate: Date, orderNumber: String?, api: any ProfileSpaceSettingsAPI ) async -> Bool { do { let request = try makeAddScheduleRequest( name: name, remark: remark, startTime: startTime, endTime: endTime, scheduleDate: scheduleDate, orderNumber: orderNumber ) setLoading(true) defer { setLoading(false) } try await api.addSchedule(request) onShowMessage?("添加成功") await load(api: api) return true } catch is CancellationError { return false } catch { onShowMessage?(error.localizedDescription) return false } } /// 删除日程并刷新个人空间配置。 func deleteSchedule(id: Int, api: any ProfileSpaceSettingsAPI) async { setLoading(true) defer { setLoading(false) } do { try await api.deleteSchedule(id: id) onShowMessage?("删除成功") await load(api: api) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription) } } private func apply(_ response: PhotographerProfileInfoResponse) { realName = response.realName nickname = response.nickname editingNickname = response.nickname if !response.avatar.isEmpty { avatarURL = response.avatar appStore.avatar = response.avatar } scenicCertification = response.scenicCertification introduction = response.description attrLabels = response.attrLabel shootLabels = response.shootLabel cameraDevices = response.cameraDevice businessStartTime = Self.shortTimeString(response.businessStartTime) businessEndTime = Self.shortTimeString(response.businessEndTime) holidayStartTime = Self.shortTimeString(response.holidayStartTime) holidayEndTime = Self.shortTimeString(response.holidayEndTime) acceptOrderStatus = response.acceptOrderStatus scheduleMap = response.schedule appStore.userName = response.nickname appStore.realName = response.realName originalSnapshot = currentSnapshot() hasChanges = false notifyStateChange() } private func adding(label rawValue: String, to labels: [String]) throws -> [String] { let label = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty else { throw ProfileSpaceSettingsValidationError.emptyLabel } guard label.count <= 20 else { throw ProfileSpaceSettingsValidationError.labelTooLong } guard labels.count < 8 else { throw ProfileSpaceSettingsValidationError.labelLimitReached } guard !labels.contains(label) else { throw ProfileSpaceSettingsValidationError.duplicateLabel } return labels + [label] } private func setLoading(_ loading: Bool) { isLoading = loading notifyStateChange() } private func updateHasChanges() { hasChanges = currentSnapshot() != originalSnapshot notifyStateChange() } private func notifyStateChange() { onStateChange?() } private func currentSnapshot() -> Snapshot { Snapshot( introduction: introduction, attrLabels: attrLabels, shootLabels: shootLabels, businessStartTime: businessStartTime, businessEndTime: businessEndTime, holidayStartTime: holidayStartTime, holidayEndTime: holidayEndTime, cameraDevices: cameraDevices, acceptOrderStatus: acceptOrderStatus ) } private static func shortTimeString(_ value: String) -> String? { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, text != "00:00:00" else { return nil } if text.count >= 5 { return String(text.prefix(5)) } return nil } static func fullTimeString(_ value: String) -> String { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) return text.count == 5 ? "\(text):00" : text } static func isStart(_ start: String, before end: String) -> Bool { minutes(from: start) < minutes(from: end) } static func minutes(from value: String) -> Int { let pieces = value.split(separator: ":").compactMap { Int($0) } guard pieces.count >= 2 else { return 0 } return pieces[0] * 60 + pieces[1] } static func dateKey(from date: Date, calendar: Calendar) -> String { let formatter = DateFormatter() formatter.calendar = calendar formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = calendar.timeZone formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: date) } /// 将日期展示为 Android 风格的 yyyy年M月d日。 static func displayDate(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String { let components = calendar.dateComponents([.year, .month, .day], from: date) return "\(components.year ?? 0)年\(components.month ?? 0)月\(components.day ?? 0)日" } /// 将日期展示为日数字。 static func displayDay(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String { String(calendar.component(.day, from: date)) } /// 将日期展示为中文星期。 static func displayWeekday(_ date: Date, calendar: Calendar = Calendar(identifier: .gregorian)) -> String { switch calendar.component(.weekday, from: date) { case 1: return "日" case 2: return "一" case 3: return "二" case 4: return "三" case 5: return "四" case 6: return "五" case 7: return "六" default: return "" } } } private struct Snapshot: Equatable { var introduction = "" var attrLabels: [String] = [] var shootLabels: [String] = [] var businessStartTime: String? var businessEndTime: String? var holidayStartTime: String? var holidayEndTime: String? var cameraDevices: [String] = [] var acceptOrderStatus = 0 }