// // ProfileSpaceModels.swift // suixinkan // // Created by Codex on 2026/6/29. // import Foundation /// 摄影师个人空间配置响应,表示当前景区下的公开展示资料和接单配置。 struct ProfileSpaceInfoResponse: Decodable, Equatable { let id: Int let scenicId: Int let photogUid: Int let realName: String let nickname: String let avatar: String let scenicCertification: [String] let description: String let attrLabel: [String] let shootLabel: [String] let cameraDevice: [String] let businessStartTime: String let businessEndTime: String let holidayStartTime: String let holidayEndTime: String let businessRange: [Int] let acceptOrderStatus: Int let schedule: [String: [ScheduleItem]] /// 响应 JSON 字段映射。 enum CodingKeys: String, CodingKey { case id case scenicId = "scenic_id" case photogUid = "photog_uid" case realName = "real_name" case nickname case avatar case scenicCertification = "scenic_certification" case description case attrLabel = "attr_label" case shootLabel = "shoot_label" case cameraDevice = "camera_device" case businessStartTime = "business_start_time" case businessEndTime = "business_end_time" case holidayStartTime = "holiday_start_time" case holidayEndTime = "holiday_end_time" case businessRange = "business_range" case acceptOrderStatus = "accept_order_status" case schedule } /// 宽松解码后端资料字段,兼容 null、数字字符串和缺失数组。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeProfileSpaceLossyInt(forKey: .id) ?? 0 scenicId = try container.decodeProfileSpaceLossyInt(forKey: .scenicId) ?? 0 photogUid = try container.decodeProfileSpaceLossyInt(forKey: .photogUid) ?? 0 realName = try container.decodeProfileSpaceLossyString(forKey: .realName) nickname = try container.decodeProfileSpaceLossyString(forKey: .nickname) avatar = try container.decodeProfileSpaceLossyString(forKey: .avatar) scenicCertification = try container.decodeProfileSpaceStringArray(forKey: .scenicCertification) description = try container.decodeProfileSpaceLossyString(forKey: .description) attrLabel = try container.decodeProfileSpaceStringArray(forKey: .attrLabel) shootLabel = try container.decodeProfileSpaceStringArray(forKey: .shootLabel) cameraDevice = try container.decodeProfileSpaceStringArray(forKey: .cameraDevice) businessStartTime = try container.decodeProfileSpaceLossyString(forKey: .businessStartTime) businessEndTime = try container.decodeProfileSpaceLossyString(forKey: .businessEndTime) holidayStartTime = try container.decodeProfileSpaceLossyString(forKey: .holidayStartTime) holidayEndTime = try container.decodeProfileSpaceLossyString(forKey: .holidayEndTime) businessRange = try container.decodeProfileSpaceIntArray(forKey: .businessRange) acceptOrderStatus = try container.decodeProfileSpaceLossyInt(forKey: .acceptOrderStatus) ?? 0 schedule = (try? container.decodeIfPresent([String: [ScheduleItem]].self, forKey: .schedule)) ?? [:] } } /// 摄影师个人空间配置更新请求。 struct UpdateProfileSpaceRequest: Encodable, Equatable { let scenicId: String let description: String let cameraDevice: [String] let attrLabel: [String] let shootLabel: [String] let businessStartTime: String let businessEndTime: String let holidayStartTime: String let holidayEndTime: String let businessRange: [Int] let acceptOrderStatus: Int /// 请求 JSON 字段映射。 enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case description case cameraDevice = "camera_device" case attrLabel = "attr_label" case shootLabel = "shoot_label" case businessStartTime = "business_start_time" case businessEndTime = "business_end_time" case holidayStartTime = "holiday_start_time" case holidayEndTime = "holiday_end_time" case businessRange = "business_range" case acceptOrderStatus = "accept_order_status" } } /// 个人空间接单状态枚举,承接后端 1-4 状态值。 enum ProfileSpaceOrderStatus: Int, CaseIterable, Identifiable { case accepting = 1 case paused = 2 case full = 3 case offline = 4 var id: Int { rawValue } /// 状态展示文案。 var title: String { switch self { case .accepting: "可接单" case .paused: "暂停接单" case .full: "已约满" case .offline: "下线" } } } /// 个人空间配置草稿,用于比较是否有未保存改动。 struct ProfileSpaceEditableSnapshot: Equatable { var nickname: String var description: String var attrLabel: [String] var shootLabel: [String] var cameraDevice: [String] var businessStartTime: Date? var businessEndTime: Date? var holidayStartTime: Date? var holidayEndTime: Date? var acceptOrderStatus: Int var avatarURL: String } /// 个人空间配置表单校验错误。 enum ProfileSpaceValidationError: LocalizedError, Equatable { case missingScenic case emptyNickname case emptyLabel case duplicateLabel case labelTooLong case labelLimit case missingWorkdayStart case missingWorkdayEnd case missingHolidayStart case missingHolidayEnd case invalidWorkdayTime case invalidHolidayTime var errorDescription: String? { switch self { case .missingScenic: "当前缺少景区信息" case .emptyNickname: "请输入昵称" case .emptyLabel: "标签不能为空" case .duplicateLabel: "标签已存在" case .labelTooLong: "标签最多20个字符" case .labelLimit: "最多8个标签" case .missingWorkdayStart: "请输入工作日开始时间" case .missingWorkdayEnd: "请输入工作日结束时间" case .missingHolidayStart: "请输入节假日开始时间" case .missingHolidayEnd: "请输入节假日结束时间" case .invalidWorkdayTime: "工作日开始时间必须早于结束时间" case .invalidHolidayTime: "节假日开始时间必须早于结束时间" } } } extension Date { /// 将日期格式化为后端需要的 HH:mm:ss 时间文本。 var profileSpaceTimeText: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "HH:mm:ss" return formatter.string(from: self) } /// 将日期格式化为空间配置页面展示的 HH:mm 文本。 var profileSpaceHourMinuteText: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "HH:mm" return formatter.string(from: self) } /// 根据 HH:mm:ss 或 HH:mm 文本生成仅含时间部分的 Date。 static func profileSpaceTime(from text: String) -> Date? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != "00:00:00" else { return nil } let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = trimmed.count == 5 ? "HH:mm" : "HH:mm:ss" return formatter.date(from: trimmed) } } private extension KeyedDecodingContainer { /// 将 String、数字和 Bool 宽松解码为字符串。 func decodeProfileSpaceLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "1" : "0" } return "" } /// 将 String、Double 和 Int 宽松解码为整数。 func decodeProfileSpaceLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } if let value = try? decodeIfPresent(String.self, forKey: key) { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) return Int(text) ?? Double(text).map(Int.init) } return nil } /// 宽松解码字符串数组,过滤空白项。 func decodeProfileSpaceStringArray(forKey key: Key) throws -> [String] { guard let values = try? decodeIfPresent([String].self, forKey: key) else { return [] } return values .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } /// 宽松解码整数数组。 func decodeProfileSpaceIntArray(forKey key: Key) throws -> [Int] { if let values = try? decodeIfPresent([Int].self, forKey: key) { return values } if let values = try? decodeIfPresent([String].self, forKey: key) { return values.compactMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) } } return [] } }