diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index 53b90b6..7744b5f 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -35,8 +35,10 @@ enum HomeRouteHandler { selectTab(.orders, from: viewController) case "photographer_stats": selectTab(.statistics, from: viewController) - case "system_settings", "space_settings": + case "system_settings": selectTab(.profile, from: viewController) + case "space_settings": + viewController.navigationController?.pushViewController(ProfileSpaceSettingsViewController(), animated: true) case "pilot_cert": let controller = RealNameAuthViewController() viewController.navigationController?.pushViewController(controller, animated: true) diff --git a/suixinkan/Features/Profile/API/ProfileAPI.swift b/suixinkan/Features/Profile/API/ProfileAPI.swift index ad4b091..f6ff9bc 100644 --- a/suixinkan/Features/Profile/API/ProfileAPI.swift +++ b/suixinkan/Features/Profile/API/ProfileAPI.swift @@ -21,6 +21,50 @@ final class ProfileAPI { ) } + /// 拉取摄影师在当前景区的个人空间配置。 + func profileInfo(scenicId: Int) async throws -> PhotographerProfileInfoResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/profile/info", + queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))] + ) + ) + } + + /// 更新摄影师个人空间配置。 + func updateProfileInfo(_ request: UpdatePhotographerProfileInfoRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/profile/info-update", + body: request + ) + ) + } + + /// 添加摄影师日程。 + func addSchedule(_ request: AddPhotographerScheduleRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/schedule/add", + body: request + ) + ) + } + + /// 删除摄影师日程。 + func deleteSchedule(id: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/schedule/delete", + body: PhotographerScheduleDeleteRequest(id: id) + ) + ) + } + /// 拉取当前登录账号可切换的景区账号和门店账号。 func switchableAccounts() async throws -> V9AuthResponse { try await client.send( diff --git a/suixinkan/Features/Profile/Models/ProfileModels.swift b/suixinkan/Features/Profile/Models/ProfileModels.swift index be88d58..5f11cb5 100644 --- a/suixinkan/Features/Profile/Models/ProfileModels.swift +++ b/suixinkan/Features/Profile/Models/ProfileModels.swift @@ -62,6 +62,241 @@ struct UpdateInfoRequest: Encodable { let avatar: String? } +/// 摄影师个人空间配置响应,对齐 Android `ProfileInfoResponse`。 +struct PhotographerProfileInfoResponse: 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: [PhotographerSchedule]] + let virtualPhone: String + + 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 + case virtualPhone = "virtual_phone" + } + + init( + id: Int = 0, + scenicId: Int = 0, + photogUid: Int = 0, + realName: String = "", + nickname: String = "", + avatar: String = "", + scenicCertification: [String] = [], + description: String = "", + attrLabel: [String] = [], + shootLabel: [String] = [], + cameraDevice: [String] = [], + businessStartTime: String = "", + businessEndTime: String = "", + holidayStartTime: String = "", + holidayEndTime: String = "", + businessRange: [Int] = [], + acceptOrderStatus: Int = 0, + schedule: [String: [PhotographerSchedule]] = [:], + virtualPhone: String = "" + ) { + self.id = id + self.scenicId = scenicId + self.photogUid = photogUid + self.realName = realName + self.nickname = nickname + self.avatar = avatar + self.scenicCertification = scenicCertification + self.description = description + self.attrLabel = attrLabel + self.shootLabel = shootLabel + self.cameraDevice = cameraDevice + self.businessStartTime = businessStartTime + self.businessEndTime = businessEndTime + self.holidayStartTime = holidayStartTime + self.holidayEndTime = holidayEndTime + self.businessRange = businessRange + self.acceptOrderStatus = acceptOrderStatus + self.schedule = schedule + self.virtualPhone = virtualPhone + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 + photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0 + realName = try container.decodeLossyString(forKey: .realName) + nickname = try container.decodeLossyString(forKey: .nickname) + avatar = try container.decodeLossyString(forKey: .avatar) + scenicCertification = try container.decodeLossyStringArray(forKey: .scenicCertification) + description = try container.decodeLossyString(forKey: .description) + attrLabel = try container.decodeLossyStringArray(forKey: .attrLabel) + shootLabel = try container.decodeLossyStringArray(forKey: .shootLabel) + cameraDevice = try container.decodeLossyStringArray(forKey: .cameraDevice) + businessStartTime = try container.decodeLossyString(forKey: .businessStartTime) + businessEndTime = try container.decodeLossyString(forKey: .businessEndTime) + holidayStartTime = try container.decodeLossyString(forKey: .holidayStartTime) + holidayEndTime = try container.decodeLossyString(forKey: .holidayEndTime) + businessRange = try container.decodeLossyIntArray(forKey: .businessRange) + acceptOrderStatus = try container.decodeLossyInt(forKey: .acceptOrderStatus) ?? 0 + schedule = try container.decodeIfPresent([String: [PhotographerSchedule]].self, forKey: .schedule) ?? [:] + virtualPhone = try container.decodeLossyString(forKey: .virtualPhone) + } +} + +/// 摄影师日程实体,对齐 Android `ScheduleEntity`。 +struct PhotographerSchedule: Decodable, Equatable, Hashable, Sendable { + let id: Int + let name: String + let remark: String + let startTime: String + let endTime: String + let scheduleDate: String + let orderNumber: String? + let userNickname: String? + let userPhone: String? + let orderStatus: Int? + let orderStatusName: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case remark + case startTime = "start_time" + case endTime = "end_time" + case scheduleDate = "schedule_date" + case orderNumber = "order_number" + case userNickname = "user_nickname" + case userPhone = "user_phone" + case orderStatus = "order_status" + case orderStatusName = "order_status_name" + } + + init( + id: Int = 0, + name: String = "", + remark: String = "", + startTime: String = "", + endTime: String = "", + scheduleDate: String = "", + orderNumber: String? = nil, + userNickname: String? = nil, + userPhone: String? = nil, + orderStatus: Int? = nil, + orderStatusName: String? = nil + ) { + self.id = id + self.name = name + self.remark = remark + self.startTime = startTime + self.endTime = endTime + self.scheduleDate = scheduleDate + self.orderNumber = orderNumber + self.userNickname = userNickname + self.userPhone = userPhone + self.orderStatus = orderStatus + self.orderStatusName = orderStatusName + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + remark = try container.decodeLossyString(forKey: .remark) + startTime = try container.decodeLossyString(forKey: .startTime) + endTime = try container.decodeLossyString(forKey: .endTime) + scheduleDate = try container.decodeLossyString(forKey: .scheduleDate) + orderNumber = try container.decodeIfPresent(String.self, forKey: .orderNumber) + userNickname = try container.decodeIfPresent(String.self, forKey: .userNickname) + userPhone = try container.decodeIfPresent(String.self, forKey: .userPhone) + orderStatus = try container.decodeLossyInt(forKey: .orderStatus) + orderStatusName = try container.decodeIfPresent(String.self, forKey: .orderStatusName) + } +} + +/// 摄影师个人空间配置更新请求,对齐 Android `UpdateProfileInfoRequest`。 +struct UpdatePhotographerProfileInfoRequest: 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 + + 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" + } +} + +/// 摄影师新增日程请求,对齐 Android `AddScheduleRequest`。 +struct AddPhotographerScheduleRequest: Encodable, Equatable { + var scenicId: String + var name: String + var remark: String + var startTime: String + var endTime: String + var scheduleDate: String + var orderNumber: String? + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case name + case remark + case startTime = "start_time" + case endTime = "end_time" + case scheduleDate = "schedule_date" + case orderNumber = "order_number" + } +} + +/// 摄影师删除日程请求,对齐 Android `ScheduleDeleteRequest`。 +struct PhotographerScheduleDeleteRequest: Encodable, Equatable { + let id: Int +} + /// 实名认证响应实体,包裹当前用户的认证详情。 struct RealNameInfoResponse: Decodable, Equatable { let realNameInfo: RealNameInfo? @@ -350,4 +585,31 @@ private extension KeyedDecodingContainer { } return nil } + + func decodeLossyStringArray(forKey key: Key) throws -> [String] { + if let values = try? decodeIfPresent([String].self, forKey: key) { + return values + } + if let values = try? decodeIfPresent([Int].self, forKey: key) { + return values.map(String.init) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? [] : [text] + } + return [] + } + + func decodeLossyIntArray(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)) } + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return [value] + } + return [] + } } diff --git a/suixinkan/Features/Profile/ViewModels/ProfileSpaceSettingsViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileSpaceSettingsViewModel.swift new file mode 100644 index 0000000..6536671 --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/ProfileSpaceSettingsViewModel.swift @@ -0,0 +1,546 @@ +// +// 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 +} diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift new file mode 100644 index 0000000..52372db --- /dev/null +++ b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift @@ -0,0 +1,1091 @@ +// +// ProfileSpaceSettingsViewController.swift +// suixinkan +// + +import Kingfisher +import PhotosUI +import SnapKit +import UIKit + +/// 个人空间配置页面,对齐 Android `ProfileInfoScreen`。 +final class ProfileSpaceSettingsViewController: BaseViewController { + + private let viewModel: ProfileSpaceSettingsViewModel + private let profileAPI: ProfileAPI + private let ossUploadService: OSSUploadService + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let bottomBar = UIView() + private let saveButton = AppButton(title: "保存") + + private let avatarImageView = UIImageView() + private let realNameLabel = UILabel() + private let nicknameField = UITextField() + private let editProfileButton = UIButton(type: .system) + private let introductionTextView = UITextView() + private let certificationWrap = ProfileTagWrapView() + + private let attrWrap = ProfileTagWrapView() + private let shootWrap = ProfileTagWrapView() + private let attrField = UITextField() + private let shootField = UITextField() + + private let businessStartButton = ProfileTimeButton() + private let businessEndButton = ProfileTimeButton() + private let holidayStartButton = ProfileTimeButton() + private let holidayEndButton = ProfileTimeButton() + + private let deviceWrap = ProfileTagWrapView() + private let addDeviceButton = UIButton(type: .system) + + private var statusButtons: [UIButton] = [] + private let dateStack = UIStackView() + private let selectedDateLabel = UILabel() + private let scheduleStack = UIStackView() + private let addScheduleButton = UIButton(type: .system) + + init( + viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(), + profileAPI: ProfileAPI = NetworkServices.shared.profileAPI, + ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService + ) { + self.viewModel = viewModel + self.profileAPI = profileAPI + self.ossUploadService = ossUploadService + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "个人空间配置" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + scrollView.alwaysBounceVertical = true + view.addSubview(scrollView) + view.addSubview(bottomBar) + scrollView.addSubview(contentStack) + bottomBar.addSubview(saveButton) + + contentStack.axis = .vertical + contentStack.spacing = 16 + contentStack.layoutMargins = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) + contentStack.isLayoutMarginsRelativeArrangement = true + + bottomBar.backgroundColor = .white + saveButton.isEnabled = false + + configureProfileCard() + configureLabelCard() + configureBusinessHoursCard() + configureDeviceCard() + configureOrderStatusCard() + configureScheduleCard() + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + saveButton.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 15, bottom: 16, right: 15)) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(scrollView.snp.width) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) + editProfileButton.addTarget(self, action: #selector(editProfileTapped), for: .touchUpInside) + addDeviceButton.addTarget(self, action: #selector(addDeviceTapped), for: .touchUpInside) + addScheduleButton.addTarget(self, action: #selector(addScheduleTapped), for: .touchUpInside) + [businessStartButton, businessEndButton, holidayStartButton, holidayEndButton].forEach { + $0.addTarget(self, action: #selector(timeButtonTapped(_:)), for: .touchUpInside) + } + let avatarTap = UITapGestureRecognizer(target: self, action: #selector(avatarTapped)) + avatarImageView.addGestureRecognizer(avatarTap) + avatarImageView.isUserInteractionEnabled = true + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.load(api: profileAPI) } + } + + @MainActor + private func applyViewModel() { + if viewModel.isLoading { + showLoading() + } else { + hideLoading() + } + saveButton.isEnabled = viewModel.hasChanges + avatarImageView.loadRemoteImage(urlString: viewModel.avatarURL) + realNameLabel.text = "姓名: \(viewModel.realName)" + nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : "昵称: \(viewModel.nickname)" + nicknameField.isEnabled = viewModel.isEditingProfile + introductionTextView.isEditable = viewModel.isEditingProfile + introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction + introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x565656) + editProfileButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "square.and.pencil"), for: .normal) + certificationWrap.setItems(viewModel.scenicCertification, style: .certification, removable: false) + attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in + self?.confirmDelete(title: "删除确认", message: "确认删除标签“\(label)”吗?") { + self?.viewModel.removeAttrLabel(label) + } + } + shootWrap.setItems(viewModel.shootLabels, style: .label, removable: true) { [weak self] label in + self?.confirmDelete(title: "删除确认", message: "确认删除拍摄说明“\(label)”吗?") { + self?.viewModel.removeShootLabel(label) + } + } + businessStartButton.setTitle(viewModel.businessStartTime ?? "00:00", for: .normal) + businessEndButton.setTitle(viewModel.businessEndTime ?? "00:00", for: .normal) + holidayStartButton.setTitle(viewModel.holidayStartTime ?? "00:00", for: .normal) + holidayEndButton.setTitle(viewModel.holidayEndTime ?? "00:00", for: .normal) + deviceWrap.setItems(viewModel.cameraDevices, style: .device, removable: true) { [weak self] device in + self?.confirmDelete(title: "删除确认", message: "确认删除设备“\(device)”吗?") { + self?.viewModel.removeDevice(device) + } + } + addDeviceButton.isHidden = viewModel.cameraDevices.count > 10 + updateStatusButtons() + rebuildDateButtons() + rebuildSchedules() + } + + private func configureProfileCard() { + let card = makeCard() + let row = UIStackView() + row.axis = .horizontal + row.alignment = .top + row.spacing = 16 + + avatarImageView.layer.cornerRadius = 58 + avatarImageView.clipsToBounds = true + avatarImageView.backgroundColor = AppColor.inputBackground + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.image = UIImage(systemName: "person.crop.circle.fill") + avatarImageView.tintColor = UIColor(hex: 0xB6BECA) + avatarImageView.snp.makeConstraints { make in + make.width.height.equalTo(116) + } + + let textStack = UIStackView() + textStack.axis = .vertical + textStack.spacing = 8 + + realNameLabel.font = .systemFont(ofSize: 14) + realNameLabel.textColor = UIColor(hex: 0x565656) + + let nicknameRow = UIStackView() + nicknameRow.axis = .horizontal + nicknameRow.alignment = .center + nicknameRow.spacing = 8 + nicknameField.font = .systemFont(ofSize: 16) + nicknameField.textColor = .black + nicknameField.borderStyle = .none + nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged) + editProfileButton.tintColor = AppColor.primary + editProfileButton.snp.makeConstraints { make in + make.width.height.equalTo(28) + } + nicknameRow.addArrangedSubview(nicknameField) + nicknameRow.addArrangedSubview(editProfileButton) + + introductionTextView.font = .systemFont(ofSize: 14) + introductionTextView.backgroundColor = .clear + introductionTextView.textContainerInset = .zero + introductionTextView.textContainer.lineFragmentPadding = 0 + introductionTextView.delegate = self + introductionTextView.isScrollEnabled = false + introductionTextView.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(24) + } + + textStack.addArrangedSubview(realNameLabel) + textStack.addArrangedSubview(nicknameRow) + textStack.addArrangedSubview(introductionTextView) + textStack.addArrangedSubview(certificationWrap) + + row.addArrangedSubview(avatarImageView) + row.addArrangedSubview(textStack) + card.addArrangedSubview(row) + contentStack.addArrangedSubview(card) + } + + private func configureLabelCard() { + let card = makeCard() + card.addArrangedSubview(makeSectionTitle("标签")) + card.addArrangedSubview(attrWrap) + card.addArrangedSubview(makeAddRow(field: attrField, placeholder: "添加标签", action: #selector(addAttrTapped))) + card.addArrangedSubview(shootWrap) + card.addArrangedSubview(makeAddRow(field: shootField, placeholder: "添加拍摄说明", action: #selector(addShootTapped))) + let hint = UILabel() + hint.text = "每个标签最多20个字符,最多8个标签" + hint.font = .systemFont(ofSize: 12) + hint.textColor = UIColor(hex: 0xB6BECA) + card.addArrangedSubview(hint) + contentStack.addArrangedSubview(card) + } + + private func configureBusinessHoursCard() { + let card = makeCard() + card.addArrangedSubview(makeSectionTitle("营业时间")) + card.addArrangedSubview(makeTimeRow(title: "工作日营业时间", start: businessStartButton, end: businessEndButton)) + card.addArrangedSubview(makeTimeRow(title: "法定节假日营业时间", start: holidayStartButton, end: holidayEndButton)) + contentStack.addArrangedSubview(card) + } + + private func configureDeviceCard() { + let card = makeCard() + card.addArrangedSubview(makeSectionTitle("相机设备")) + card.addArrangedSubview(deviceWrap) + addDeviceButton.setTitle("+ 添加设备", for: .normal) + addDeviceButton.setTitleColor(AppColor.primary, for: .normal) + addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14) + addDeviceButton.layer.borderColor = AppColor.primary.cgColor + addDeviceButton.layer.borderWidth = 1 + addDeviceButton.layer.cornerRadius = 4 + addDeviceButton.snp.makeConstraints { make in + make.height.equalTo(32) + } + card.addArrangedSubview(addDeviceButton) + contentStack.addArrangedSubview(card) + } + + private func configureOrderStatusCard() { + let card = makeCard() + card.addArrangedSubview(makeSectionTitle("接单状态")) + let labels = ["可接单", "暂停接单", "已约满", "下线"] + for rowIndex in 0..<2 { + let row = UIStackView() + row.axis = .horizontal + row.spacing = 15 + row.distribution = .fillEqually + for index in 0..<2 { + let status = rowIndex * 2 + index + 1 + let button = UIButton(type: .system) + button.setTitle(labels[status - 1], for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14) + button.tag = status + button.layer.cornerRadius = 8 + button.layer.borderWidth = 1 + button.addTarget(self, action: #selector(statusTapped(_:)), for: .touchUpInside) + button.snp.makeConstraints { make in + make.height.equalTo(52) + } + statusButtons.append(button) + row.addArrangedSubview(button) + } + card.addArrangedSubview(row) + } + contentStack.addArrangedSubview(card) + } + + private func configureScheduleCard() { + let card = makeCard() + card.addArrangedSubview(makeSectionTitle("日程安排")) + dateStack.axis = .horizontal + dateStack.spacing = 4 + dateStack.distribution = .fillEqually + card.addArrangedSubview(dateStack) + + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + header.spacing = 12 + selectedDateLabel.font = .systemFont(ofSize: 14) + selectedDateLabel.textColor = UIColor(hex: 0x252525) + addScheduleButton.setTitle("添加日程", for: .normal) + addScheduleButton.setTitleColor(AppColor.primary, for: .normal) + addScheduleButton.titleLabel?.font = .systemFont(ofSize: 14) + header.addArrangedSubview(selectedDateLabel) + header.addArrangedSubview(UIView()) + header.addArrangedSubview(addScheduleButton) + card.addArrangedSubview(header) + + scheduleStack.axis = .vertical + scheduleStack.spacing = 12 + card.addArrangedSubview(scheduleStack) + contentStack.addArrangedSubview(card) + } + + private func makeCard() -> UIStackView { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 16 + stack.backgroundColor = .white + stack.layer.cornerRadius = 12 + stack.clipsToBounds = true + stack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + stack.isLayoutMarginsRelativeArrangement = true + return stack + } + + private func makeSectionTitle(_ title: String) -> UILabel { + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 14, weight: .medium) + label.textColor = UIColor(hex: 0x252525) + return label + } + + private func makeAddRow(field: UITextField, placeholder: String, action: Selector) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.spacing = 8 + row.alignment = .fill + field.placeholder = placeholder + field.font = .systemFont(ofSize: 14) + field.layer.cornerRadius = 8 + field.layer.borderColor = AppColor.border.cgColor + field.layer.borderWidth = 1 + field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + field.leftViewMode = .always + field.snp.makeConstraints { make in + make.height.equalTo(36) + } + let button = UIButton(type: .system) + button.setTitle("添加标签", for: .normal) + button.setTitleColor(.white, for: .normal) + button.backgroundColor = AppColor.primary + button.titleLabel?.font = .systemFont(ofSize: 14) + button.layer.cornerRadius = 4 + button.addTarget(self, action: action, for: .touchUpInside) + button.snp.makeConstraints { make in + make.width.equalTo(80) + make.height.equalTo(36) + } + row.addArrangedSubview(field) + row.addArrangedSubview(button) + return row + } + + private func makeTimeRow(title: String, start: ProfileTimeButton, end: ProfileTimeButton) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 10 + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14) + titleLabel.textColor = UIColor(hex: 0x565656) + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let toLabel = UILabel() + toLabel.text = "至" + toLabel.font = .systemFont(ofSize: 14) + toLabel.textColor = UIColor(hex: 0x565656) + row.addArrangedSubview(titleLabel) + row.addArrangedSubview(start) + row.addArrangedSubview(toLabel) + row.addArrangedSubview(end) + [start, end].forEach { button in + button.snp.makeConstraints { make in + make.width.equalTo(70) + make.height.equalTo(36) + } + } + return row + } + + private func updateStatusButtons() { + for button in statusButtons { + let selected = button.tag == viewModel.acceptOrderStatus + button.backgroundColor = selected ? AppColor.primary : .white + button.layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xB6BECA)).cgColor + button.setTitleColor(selected ? .white : UIColor(hex: 0x565656), for: .normal) + } + } + + private func rebuildDateButtons() { + dateStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for date in viewModel.weekDates { + let button = ProfileDateButton() + button.configure( + weekday: ProfileSpaceSettingsViewModel.displayWeekday(date), + day: ProfileSpaceSettingsViewModel.displayDay(date), + selected: Calendar.current.isDate(date, inSameDayAs: viewModel.selectedDate) + ) + button.date = date + button.addTarget(self, action: #selector(dateTapped(_:)), for: .touchUpInside) + dateStack.addArrangedSubview(button) + } + selectedDateLabel.text = ProfileSpaceSettingsViewModel.displayDate(viewModel.selectedDate) + } + + private func rebuildSchedules() { + scheduleStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + let schedules = viewModel.selectedDateSchedules + guard !schedules.isEmpty else { + let empty = UILabel() + empty.text = "该日无日程" + empty.textColor = AppColor.textTertiary + empty.font = .systemFont(ofSize: 14) + empty.textAlignment = .center + empty.snp.makeConstraints { make in + make.height.equalTo(76) + } + scheduleStack.addArrangedSubview(empty) + return + } + for schedule in schedules { + let item = ProfileScheduleItemView(schedule: schedule) + item.onDelete = { [weak self] in + self?.confirmDelete(title: "删除确认", message: "确认删除该日程吗?") { + guard let self else { return } + Task { await self.viewModel.deleteSchedule(id: schedule.id, api: self.profileAPI) } + } + } + item.onCall = { [weak self] phone in + self?.callPhone(phone) + } + scheduleStack.addArrangedSubview(item) + } + } + + private func confirmDelete(title: String, message: String, onConfirm: @escaping () -> Void) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确定", style: .destructive) { _ in onConfirm() }) + present(alert, animated: true) + } + + private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { + let alert = UIAlertController(title: "选择时间", message: "\n\n\n\n\n\n\n", preferredStyle: .actionSheet) + let picker = UIDatePicker() + picker.datePickerMode = .time + picker.preferredDatePickerStyle = .wheels + picker.locale = Locale(identifier: "zh_CN") + if let initialTime { + picker.date = date(fromTime: initialTime) + } + alert.view.addSubview(picker) + picker.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.top.equalToSuperview().offset(42) + make.height.equalTo(160) + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in + onConfirm(self.timeString(from: picker.date)) + }) + present(alert, animated: true) + } + + private func date(fromTime value: String) -> Date { + var components = DateComponents() + let pieces = value.split(separator: ":").compactMap { Int($0) } + components.hour = pieces.first ?? 0 + components.minute = pieces.dropFirst().first ?? 0 + return Calendar.current.date(from: components) ?? Date() + } + + private func timeString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + private func callPhone(_ phone: String) { + let digits = phone.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: "tel://\(digits)"), UIApplication.shared.canOpenURL(url) else { return } + UIApplication.shared.open(url) + } + + @objc private func saveTapped() { + view.endEditing(true) + Task { await viewModel.save(api: profileAPI) } + } + + @objc private func editProfileTapped() { + view.endEditing(true) + Task { await viewModel.toggleProfileEditing(api: profileAPI) } + } + + @objc private func nicknameChanged() { + viewModel.updateEditingNickname(nicknameField.text ?? "") + } + + @objc private func addAttrTapped() { + viewModel.addAttrLabel(attrField.text ?? "") + attrField.text = "" + } + + @objc private func addShootTapped() { + viewModel.addShootLabel(shootField.text ?? "") + shootField.text = "" + } + + @objc private func timeButtonTapped(_ sender: ProfileTimeButton) { + if sender === businessStartButton { + showTimePicker(initialTime: viewModel.businessStartTime) { [weak self] in self?.viewModel.updateBusinessStartTime($0) } + } else if sender === businessEndButton { + showTimePicker(initialTime: viewModel.businessEndTime) { [weak self] in self?.viewModel.updateBusinessEndTime($0) } + } else if sender === holidayStartButton { + showTimePicker(initialTime: viewModel.holidayStartTime) { [weak self] in self?.viewModel.updateHolidayStartTime($0) } + } else { + showTimePicker(initialTime: viewModel.holidayEndTime) { [weak self] in self?.viewModel.updateHolidayEndTime($0) } + } + } + + @objc private func addDeviceTapped() { + let alert = UIAlertController(title: "添加设备", message: nil, preferredStyle: .alert) + alert.addTextField { field in + field.placeholder = "请输入设备名称" + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in + self?.viewModel.addDevice(alert.textFields?.first?.text ?? "") + }) + present(alert, animated: true) + } + + @objc private func statusTapped(_ sender: UIButton) { + viewModel.updateAcceptOrderStatus(sender.tag) + } + + @objc private func dateTapped(_ sender: ProfileDateButton) { + guard let date = sender.date else { return } + viewModel.selectDate(date) + } + + @objc private func addScheduleTapped() { + let controller = ProfileAddScheduleViewController( + selectedDate: viewModel.selectedDate, + viewModel: viewModel, + profileAPI: profileAPI + ) + navigationController?.pushViewController(controller, animated: true) + } + + @objc private func avatarTapped() { + guard viewModel.isEditingProfile else { return } + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = 1 + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } +} + +extension ProfileSpaceSettingsViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + viewModel.updateIntroduction(textView.text) + } +} + +extension ProfileSpaceSettingsViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let provider = results.first?.itemProvider else { return } + Task { + do { + let data = try await ProfileAvatarPickerLoader.loadImageData(from: provider) + let scenicId = AppStore.shared.currentScenicId + let fileURL = try await ossUploadService.uploadUserAvatar( + data: data, + fileName: "avatar_\(UUID().uuidString).jpg", + scenicId: scenicId + ) { _ in } + try await profileAPI.updateUserInfo(nickname: nil, password: nil, avatar: fileURL) + await MainActor.run { + viewModel.updateAvatarURL(fileURL) + showToast("修改成功") + } + } catch { + await MainActor.run { showToast(error.localizedDescription) } + } + } + } +} + +/// 添加日程页面,对齐 Android `AddScheduleScreen`。 +final class ProfileAddScheduleViewController: BaseViewController { + + private let selectedDate: Date + private let viewModel: ProfileSpaceSettingsViewModel + private let profileAPI: ProfileAPI + + private let contentStack = UIStackView() + private let bottomBar = UIView() + private let saveButton = AppButton(title: "保存") + private let nameField = UITextField() + private let startButton = ProfileTimeButton() + private let endButton = ProfileTimeButton() + private let orderButton = UIButton(type: .system) + private let remarkTextView = UITextView() + private let countLabel = UILabel() + + init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) { + self.selectedDate = selectedDate + self.viewModel = viewModel + self.profileAPI = profileAPI + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "添加日程" + } + + override func setupUI() { + view.addSubview(contentStack) + view.addSubview(bottomBar) + bottomBar.addSubview(saveButton) + bottomBar.backgroundColor = .white + + contentStack.axis = .vertical + contentStack.spacing = 20 + contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + contentStack.isLayoutMarginsRelativeArrangement = true + contentStack.backgroundColor = .white + contentStack.layer.cornerRadius = 12 + + contentStack.addArrangedSubview(makeTitle("日程安排")) + configureNameField() + contentStack.addArrangedSubview(nameField) + contentStack.addArrangedSubview(makeTimeRow()) + configureOrderButton() + contentStack.addArrangedSubview(orderButton) + configureRemarkView() + contentStack.addArrangedSubview(remarkTextView) + contentStack.addArrangedSubview(countLabel) + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + saveButton.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 15, bottom: 16, right: 15)) + } + contentStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(16) + make.leading.trailing.equalToSuperview().inset(16) + } + nameField.snp.makeConstraints { make in + make.height.equalTo(44) + } + orderButton.snp.makeConstraints { make in + make.height.equalTo(44) + } + remarkTextView.snp.makeConstraints { make in + make.height.equalTo(100) + } + } + + override func bindActions() { + saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) + startButton.addTarget(self, action: #selector(startTapped), for: .touchUpInside) + endButton.addTarget(self, action: #selector(endTapped), for: .touchUpInside) + orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside) + remarkTextView.delegate = self + } + + private func makeTitle(_ title: String) -> UILabel { + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 14, weight: .medium) + label.textColor = .black + return label + } + + private func configureNameField() { + nameField.placeholder = "请输入日程名称" + nameField.font = .systemFont(ofSize: 14) + nameField.layer.borderColor = AppColor.border.cgColor + nameField.layer.borderWidth = 1 + nameField.layer.cornerRadius = 8 + nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + nameField.leftViewMode = .always + } + + private func makeTimeRow() -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 12 + let title = UILabel() + title.text = "时间" + title.font = .systemFont(ofSize: 14) + title.textColor = UIColor(hex: 0x565656) + let toLabel = UILabel() + toLabel.text = "至" + toLabel.font = .systemFont(ofSize: 14) + toLabel.textColor = UIColor(hex: 0x565656) + [startButton, endButton].forEach { button in + button.setTitle("请选择", for: .normal) + button.snp.makeConstraints { make in + make.width.equalTo(78) + make.height.equalTo(36) + } + } + row.addArrangedSubview(title) + row.addArrangedSubview(UIView()) + row.addArrangedSubview(startButton) + row.addArrangedSubview(toLabel) + row.addArrangedSubview(endButton) + return row + } + + private func configureOrderButton() { + orderButton.setTitle("关联订单 (可选)", for: .normal) + orderButton.setTitleColor(.black, for: .normal) + orderButton.contentHorizontalAlignment = .left + orderButton.titleLabel?.font = .systemFont(ofSize: 14) + orderButton.layer.borderColor = AppColor.border.cgColor + orderButton.layer.borderWidth = 1 + orderButton.layer.cornerRadius = 8 + orderButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + } + + private func configureRemarkView() { + remarkTextView.font = .systemFont(ofSize: 14) + remarkTextView.textColor = UIColor(hex: 0x565656) + remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor + remarkTextView.layer.borderWidth = 1 + remarkTextView.layer.cornerRadius = 8 + countLabel.text = "0/200" + countLabel.textAlignment = .right + countLabel.font = .systemFont(ofSize: 12) + countLabel.textColor = UIColor(hex: 0xB6BECA) + } + + private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { + let alert = UIAlertController(title: "选择时间", message: "\n\n\n\n\n\n\n", preferredStyle: .actionSheet) + let picker = UIDatePicker() + picker.datePickerMode = .time + picker.preferredDatePickerStyle = .wheels + picker.locale = Locale(identifier: "zh_CN") + alert.view.addSubview(picker) + picker.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.top.equalToSuperview().offset(42) + make.height.equalTo(160) + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + onConfirm(formatter.string(from: picker.date)) + }) + present(alert, animated: true) + _ = initialTime + } + + @objc private func startTapped() { + showTimePicker(initialTime: startButton.title(for: .normal)) { [weak self] value in + if let end = self?.endButton.title(for: .normal), end != "请选择", !ProfileSpaceSettingsViewModel.isStart(value, before: end) { + self?.showToast("结束时间必须晚于开始时间,已清除结束时间") + self?.endButton.setTitle("请选择", for: .normal) + } + self?.startButton.setTitle(value, for: .normal) + } + } + + @objc private func endTapped() { + showTimePicker(initialTime: endButton.title(for: .normal)) { [weak self] value in + if let start = self?.startButton.title(for: .normal), start != "请选择", !ProfileSpaceSettingsViewModel.isStart(start, before: value) { + self?.showToast("结束时间必须晚于开始时间") + return + } + self?.endButton.setTitle(value, for: .normal) + } + } + + @objc private func orderTapped() { + showToast("关联订单 (可选)") + } + + @objc private func saveTapped() { + view.endEditing(true) + let start = startButton.title(for: .normal) == "请选择" ? nil : startButton.title(for: .normal) + let end = endButton.title(for: .normal) == "请选择" ? nil : endButton.title(for: .normal) + Task { + let success = await viewModel.addSchedule( + name: nameField.text ?? "", + remark: remarkTextView.text ?? "", + startTime: start, + endTime: end, + scheduleDate: selectedDate, + orderNumber: nil, + api: profileAPI + ) + if success { + await MainActor.run { navigationController?.popViewController(animated: true) } + } + } + } +} + +extension ProfileAddScheduleViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + if textView.text.count > 200 { + textView.text = String(textView.text.prefix(200)) + } + countLabel.text = "\(textView.text.count)/200" + } +} + +/// 个人空间配置时间按钮。 +private final class ProfileTimeButton: UIButton { + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel?.font = .systemFont(ofSize: 14) + setTitleColor(AppColor.textPrimary, for: .normal) + backgroundColor = .white + layer.cornerRadius = 8 + layer.borderColor = AppColor.border.cgColor + layer.borderWidth = 1 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 个人空间配置日期按钮。 +private final class ProfileDateButton: UIButton { + var date: Date? + + func configure(weekday: String, day: String, selected: Bool) { + let title = "\(weekday)\n\(day)" + setTitle(title, for: .normal) + titleLabel?.numberOfLines = 2 + titleLabel?.textAlignment = .center + titleLabel?.font = .systemFont(ofSize: 14) + setTitleColor(selected ? .white : UIColor(hex: 0x565656), for: .normal) + backgroundColor = selected ? AppColor.primary : AppColor.inputBackground + layer.cornerRadius = 4 + snp.makeConstraints { make in + make.height.equalTo(58) + } + } +} + +/// 个人空间配置标签换行容器。 +private final class ProfileTagWrapView: UIView { + enum Style { + case label + case certification + case device + } + + private var itemViews: [UIView] = [] + private var removeHandler: ((String) -> Void)? + + func setItems(_ items: [String], style: Style, removable: Bool, onRemove: ((String) -> Void)? = nil) { + subviews.forEach { $0.removeFromSuperview() } + itemViews = items.map { item in + let view = makeItem(title: item, style: style, removable: removable) + addSubview(view) + return view + } + removeHandler = onRemove + invalidateIntrinsicContentSize() + setNeedsLayout() + } + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: measuredHeight(for: bounds.width > 0 ? bounds.width : UIScreen.main.bounds.width - 62)) + } + + override func layoutSubviews() { + super.layoutSubviews() + layoutItems(width: bounds.width) + } + + private func layoutItems(width: CGFloat) { + let spacing: CGFloat = 8 + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + for view in itemViews { + let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + if x > 0, x + size.width > width { + x = 0 + y += rowHeight + spacing + rowHeight = 0 + } + view.frame = CGRect(x: x, y: y, width: size.width, height: size.height) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } + + private func measuredHeight(for width: CGFloat) -> CGFloat { + guard !itemViews.isEmpty else { return 1 } + let spacing: CGFloat = 8 + var x: CGFloat = 0 + var height: CGFloat = 0 + var rowHeight: CGFloat = 0 + for view in itemViews { + let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + if x > 0, x + size.width > width { + x = 0 + height += rowHeight + spacing + rowHeight = 0 + } + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return height + rowHeight + } + + private func makeItem(title: String, style: Style, removable: Bool) -> UIView { + let button = UIButton(type: .system) + button.setTitle(removable ? "\(title) ×" : title, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: style == .certification ? 12 : 14) + button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + button.layer.cornerRadius = 4 + switch style { + case .label: + button.backgroundColor = AppColor.primaryLight + button.setTitleColor(AppColor.primary, for: .normal) + case .certification: + button.backgroundColor = AppColor.warningBackground + button.setTitleColor(AppColor.warning, for: .normal) + case .device: + button.backgroundColor = AppColor.inputBackground + button.setTitleColor(UIColor(hex: 0x565656), for: .normal) + } + if removable { + button.addAction(UIAction { [weak self] _ in + self?.removeHandler?(title) + }, for: .touchUpInside) + } + return button + } +} + +/// 个人空间配置日程列表项。 +private final class ProfileScheduleItemView: UIStackView { + var onDelete: (() -> Void)? + var onCall: ((String) -> Void)? + + private let schedule: PhotographerSchedule + + init(schedule: PhotographerSchedule) { + self.schedule = schedule + super.init(frame: .zero) + setupUI() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupUI() { + axis = .vertical + spacing = 4 + backgroundColor = UIColor(hex: 0xF9FAFB) + layer.cornerRadius = 8 + layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + isLayoutMarginsRelativeArrangement = true + + let top = UIStackView() + top.axis = .horizontal + top.spacing = 8 + let title = UILabel() + title.text = "\(schedule.startTime) - \(schedule.endTime) \(schedule.name)" + title.font = .systemFont(ofSize: 14) + title.textColor = UIColor(hex: 0x565656) + title.numberOfLines = 1 + let delete = UIButton(type: .system) + delete.setTitle("删除", for: .normal) + delete.setTitleColor(AppColor.danger, for: .normal) + delete.titleLabel?.font = .systemFont(ofSize: 14) + delete.addAction(UIAction { [weak self] _ in self?.onDelete?() }, for: .touchUpInside) + top.addArrangedSubview(title) + top.addArrangedSubview(delete) + addArrangedSubview(top) + + if let order = schedule.orderNumber, !order.isEmpty { + addArrangedSubview(makeLine("订单号: \(order)")) + } + if let phone = schedule.userPhone, !phone.isEmpty { + let row = UIStackView() + row.axis = .horizontal + row.spacing = 16 + row.addArrangedSubview(makeLine("客户手机号: \(Self.maskPhone(phone))")) + let call = UIButton(type: .system) + call.setTitle("拨打", for: .normal) + call.setTitleColor(AppColor.primary, for: .normal) + call.titleLabel?.font = .systemFont(ofSize: 14) + call.addAction(UIAction { [weak self] _ in self?.onCall?(phone) }, for: .touchUpInside) + row.addArrangedSubview(call) + addArrangedSubview(row) + } + if !schedule.remark.isEmpty { + addArrangedSubview(makeLine("备注信息: \(schedule.remark)")) + } + } + + private func makeLine(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 14) + label.textColor = UIColor(hex: 0x565656) + label.numberOfLines = 1 + return label + } + + private static func maskPhone(_ phone: String) -> String { + guard phone.count >= 11 else { return phone } + let prefix = phone.prefix(3) + let suffix = phone.suffix(phone.count - 7) + return "\(prefix)****\(suffix)" + } +} + +/// 头像选择器数据加载器。 +private enum ProfileAvatarPickerLoader { + static func loadImageData(from provider: NSItemProvider) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + guard provider.canLoadObject(ofClass: UIImage.self) else { + continuation.resume(throwing: OSSUploadError.unsupportedFileType) + return + } + provider.loadObject(ofClass: UIImage.self) { object, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { + continuation.resume(throwing: OSSUploadError.emptyFile) + return + } + continuation.resume(returning: data) + } + } + } +} diff --git a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift new file mode 100644 index 0000000..1ab2bb3 --- /dev/null +++ b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift @@ -0,0 +1,334 @@ +// +// ProfileSpaceSettingsViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 个人空间配置 ViewModel 与首页路由测试。 +final class ProfileSpaceSettingsViewModelTests: XCTestCase { + + private var defaults: UserDefaults! + private var appStore: AppStore! + private var calendar: Calendar! + private var fixedNow: Date! + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: "ProfileSpaceSettingsViewModelTests")! + defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests") + appStore = AppStore(defaults: defaults) + appStore.currentScenicId = 11 + calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + fixedNow = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8))! + } + + override func tearDown() { + AppStore.shared.currentScenicId = 0 + appStore.logout() + defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests") + super.tearDown() + } + + func testProfileInfoDecodeSupportsEmptyStringsArraysAndScheduleMap() throws { + let json = """ + { + "id": "9", + "scenic_id": "11", + "photog_uid": "", + "real_name": "", + "nickname": "小林", + "avatar": "", + "scenic_certification": ["西湖", ""], + "description": "", + "attr_label": ["航拍", 8], + "shoot_label": "", + "camera_device": ["Sony A7"], + "business_start_time": "09:00:00", + "business_end_time": "18:00:00", + "holiday_start_time": "", + "holiday_end_time": "20:00:00", + "business_range": ["1", 2], + "accept_order_status": "1", + "virtual_phone": "", + "schedule": { + "2026-07-08": [{ + "id": "22", + "name": "旅拍", + "remark": "", + "start_time": "10:00", + "end_time": "11:00", + "schedule_date": "2026-07-08", + "order_number": "NO1", + "user_nickname": "游客", + "user_phone": "13800000000", + "order_status": "2", + "order_status_name": "待拍摄" + }] + } + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(PhotographerProfileInfoResponse.self, from: json) + + XCTAssertEqual(response.id, 9) + XCTAssertEqual(response.photogUid, 0) + XCTAssertEqual(response.attrLabel, ["航拍", "8"]) + XCTAssertEqual(response.shootLabel, []) + XCTAssertEqual(response.businessRange, [1, 2]) + XCTAssertEqual(response.schedule["2026-07-08"]?.first?.id, 22) + XCTAssertEqual(response.schedule["2026-07-08"]?.first?.userPhone, "13800000000") + } + + func testLabelValidationCoversEmptyTooLongDuplicateAndLimit() { + let viewModel = makeViewModel() + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + viewModel.addAttrLabel(" ") + XCTAssertEqual(messages.last, "标签不能为空") + + viewModel.addAttrLabel(String(repeating: "a", count: 21)) + XCTAssertEqual(messages.last, "标签最多20个字符") + + viewModel.addAttrLabel("航拍") + viewModel.addAttrLabel("航拍") + XCTAssertEqual(viewModel.attrLabels, ["航拍"]) + XCTAssertEqual(messages.last, "标签已存在") + + for index in 1...7 { + viewModel.addAttrLabel("标签\(index)") + } + viewModel.addAttrLabel("第九个") + XCTAssertEqual(viewModel.attrLabels.count, 8) + XCTAssertEqual(messages.last, "最多8个标签") + } + + func testBusinessTimeValidationRejectsInvalidRanges() throws { + let viewModel = makeViewModel() + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + viewModel.updateBusinessEndTime("10:00") + viewModel.updateBusinessStartTime("11:00") + XCTAssertNil(viewModel.businessStartTime) + XCTAssertEqual(messages.last, "开始时间不能大于结束时间") + + viewModel.updateHolidayStartTime("13:00") + viewModel.updateHolidayEndTime("12:00") + XCTAssertNil(viewModel.holidayEndTime) + XCTAssertEqual(messages.last, "结束时间不能小于开始时间") + + viewModel.updateBusinessStartTime("09:00") + viewModel.updateBusinessEndTime("18:00") + viewModel.updateHolidayStartTime("08:00") + viewModel.updateHolidayEndTime("20:00") + + viewModel.updateBusinessEndTime("08:00") + XCTAssertThrowsError(try viewModel.makeSaveRequest()) + } + + func testHasChangesAndSaveRequestFieldsMatchAndroid() async throws { + let api = FakeProfileSpaceSettingsAPI(response: .loaded()) + let viewModel = makeViewModel() + + await viewModel.load(api: api) + XCTAssertFalse(viewModel.hasChanges) + + viewModel.updateIntroduction("更新后的简介") + viewModel.addDevice("Nikon Z8") + viewModel.updateAcceptOrderStatus(2) + XCTAssertTrue(viewModel.hasChanges) + + await viewModel.save(api: api) + + let request = try XCTUnwrap(api.lastUpdateRequest) + XCTAssertEqual(request.scenicId, "11") + XCTAssertEqual(request.description, "更新后的简介") + XCTAssertEqual(request.cameraDevice, ["Sony A7", "Nikon Z8"]) + XCTAssertEqual(request.attrLabel, ["航拍"]) + XCTAssertEqual(request.shootLabel, ["亲子"]) + XCTAssertEqual(request.businessStartTime, "09:00:00") + XCTAssertEqual(request.businessEndTime, "18:00:00") + XCTAssertEqual(request.holidayStartTime, "10:00:00") + XCTAssertEqual(request.holidayEndTime, "20:00:00") + XCTAssertEqual(request.businessRange, []) + XCTAssertEqual(request.acceptOrderStatus, 2) + XCTAssertFalse(viewModel.hasChanges) + } + + func testAddScheduleValidationAndDeleteId() async throws { + let api = FakeProfileSpaceSettingsAPI(response: .loaded()) + let viewModel = makeViewModel() + let scheduleDate = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 7, day: 9))) + + XCTAssertThrowsError(try viewModel.makeAddScheduleRequest( + name: "", + remark: "内容", + startTime: "10:00", + endTime: "11:00", + scheduleDate: scheduleDate, + orderNumber: nil + )) { error in + XCTAssertEqual(error as? ProfileSpaceSettingsValidationError, .emptyScheduleName) + } + + XCTAssertThrowsError(try viewModel.makeAddScheduleRequest( + name: "日程", + remark: "内容", + startTime: "12:00", + endTime: "11:00", + scheduleDate: scheduleDate, + orderNumber: nil + )) { error in + XCTAssertEqual(error as? ProfileSpaceSettingsValidationError, .invalidScheduleTime) + } + + let didAdd = await viewModel.addSchedule( + name: " 日程 ", + remark: "内容", + startTime: "10:00", + endTime: "11:00", + scheduleDate: scheduleDate, + orderNumber: " ", + api: api + ) + + XCTAssertTrue(didAdd) + XCTAssertEqual(api.lastAddScheduleRequest?.scenicId, "11") + XCTAssertEqual(api.lastAddScheduleRequest?.name, "日程") + XCTAssertEqual(api.lastAddScheduleRequest?.scheduleDate, "2026-07-09") + XCTAssertNil(api.lastAddScheduleRequest?.orderNumber) + + await viewModel.deleteSchedule(id: 33, api: api) + XCTAssertEqual(api.lastDeletedScheduleId, 33) + } + + func testSpaceSettingsRoutePushesProfileSpaceSettingsPage() { + AppStore.shared.currentScenicId = 11 + let root = UIViewController() + let navigationController = UINavigationController(rootViewController: root) + + HomeRouteHandler.open(uri: "space_settings", from: root, storeItem: nil) + + XCTAssertTrue(navigationController.topViewController is ProfileSpaceSettingsViewController) + } + + func testSystemSettingsRouteStillSelectsProfileTab() { + AppStore.shared.currentScenicId = 11 + let tabBarController = MainTabBarController() + tabBarController.loadViewIfNeeded() + guard + let navigationController = tabBarController.selectedViewController as? UINavigationController, + let root = navigationController.viewControllers.first + else { + XCTFail("首页 Tab 未正确初始化") + return + } + + HomeRouteHandler.open(uri: "system_settings", from: root, storeItem: nil) + + XCTAssertEqual(tabBarController.selectedIndex, 4) + } + + private func makeViewModel() -> ProfileSpaceSettingsViewModel { + ProfileSpaceSettingsViewModel( + appStore: appStore, + calendar: calendar, + nowProvider: { self.fixedNow } + ) + } +} + +@MainActor +private final class FakeProfileSpaceSettingsAPI: ProfileSpaceSettingsAPI { + var response: PhotographerProfileInfoResponse + var lastUpdateRequest: UpdatePhotographerProfileInfoRequest? + var lastAddScheduleRequest: AddPhotographerScheduleRequest? + var lastDeletedScheduleId: Int? + + init(response: PhotographerProfileInfoResponse) { + self.response = response + } + + func profileInfo(scenicId: Int) async throws -> PhotographerProfileInfoResponse { + response + } + + func updateProfileInfo(_ request: UpdatePhotographerProfileInfoRequest) async throws { + lastUpdateRequest = request + response = PhotographerProfileInfoResponse( + id: response.id, + scenicId: Int(request.scenicId) ?? 0, + photogUid: response.photogUid, + realName: response.realName, + nickname: response.nickname, + avatar: response.avatar, + scenicCertification: response.scenicCertification, + description: request.description, + attrLabel: request.attrLabel, + shootLabel: request.shootLabel, + cameraDevice: request.cameraDevice, + businessStartTime: request.businessStartTime, + businessEndTime: request.businessEndTime, + holidayStartTime: request.holidayStartTime, + holidayEndTime: request.holidayEndTime, + businessRange: request.businessRange, + acceptOrderStatus: request.acceptOrderStatus, + schedule: response.schedule, + virtualPhone: response.virtualPhone + ) + } + + func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws {} + + func addSchedule(_ request: AddPhotographerScheduleRequest) async throws { + lastAddScheduleRequest = request + } + + func deleteSchedule(id: Int) async throws { + lastDeletedScheduleId = id + } +} + +private extension PhotographerProfileInfoResponse { + static func loaded() -> PhotographerProfileInfoResponse { + PhotographerProfileInfoResponse( + id: 1, + scenicId: 11, + photogUid: 100, + realName: "林摄影", + nickname: "小林", + avatar: "https://example.com/a.jpg", + scenicCertification: ["西湖"], + description: "简介", + attrLabel: ["航拍"], + shootLabel: ["亲子"], + cameraDevice: ["Sony A7"], + businessStartTime: "09:00:00", + businessEndTime: "18:00:00", + holidayStartTime: "10:00:00", + holidayEndTime: "20:00:00", + businessRange: [], + acceptOrderStatus: 1, + schedule: [ + "2026-07-08": [ + PhotographerSchedule( + id: 22, + name: "旅拍", + remark: "西湖", + startTime: "10:00", + endTime: "11:00", + scheduleDate: "2026-07-08", + userNickname: "游客", + userPhone: "13800000000" + ), + ], + ], + virtualPhone: "" + ) + } +}