// // ScheduleViewModels.swift // suixinkan // // Created by Codex on 2026/6/24. // import Foundation /// 排班错误实体,表示表单校验失败。 enum ScheduleValidationError: LocalizedError, Equatable { case missingScenic case missingName case invalidTime /// 错误文案。 var errorDescription: String? { switch self { case .missingScenic: return "当前缺少景区信息" case .missingName: return "请输入日程名称" case .invalidTime: return "结束时间必须晚于开始时间" } } } /// 排班管理 ViewModel,负责月份日期标记、某日列表和删除操作。 @MainActor final class ScheduleManagementViewModel { var onChange: (() -> Void)? var monthDate = Date.scheduleFirstDayOfMonth { didSet { onChange?() } } var selectedDate = Date() { didSet { onChange?() } } private(set) var markedDays: Set = [] { didSet { onChange?() } } private(set) var items: [ScheduleItem] = [] { didSet { onChange?() } } private(set) var loading = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } /// 切换到上一个月并重新加载。 func previousMonth(api: any ScheduleServing, scenicId: Int?) async { monthDate = Calendar.current.date(byAdding: .month, value: -1, to: monthDate) ?? monthDate selectedDate = monthDate await reload(api: api, scenicId: scenicId) } /// 切换到下一个月并重新加载。 func nextMonth(api: any ScheduleServing, scenicId: Int?) async { monthDate = Calendar.current.date(byAdding: .month, value: 1, to: monthDate) ?? monthDate selectedDate = monthDate await reload(api: api, scenicId: scenicId) } /// 选择日期并加载当天排班。 func selectDate(_ date: Date, api: any ScheduleServing, scenicId: Int?) async { selectedDate = date await loadDay(api: api, scenicId: scenicId) } /// 重新加载月份日期标记和当前日排班。 func reload(api: any ScheduleServing, scenicId: Int?) async { guard let scenicId else { reset() return } loading = true errorMessage = nil defer { loading = false } do { async let days = api.monthScheduleDays(scenicId: scenicId, yearMonth: monthDate.scheduleYearMonthText) async let list = api.dayScheduleList(scenicId: scenicId, date: selectedDate.scheduleDayText) markedDays = Set(try await days) items = try await list } catch { items = [] markedDays = [] errorMessage = error.localizedDescription } } /// 只加载当前选中日期的排班列表。 func loadDay(api: any ScheduleServing, scenicId: Int?) async { guard let scenicId else { reset() return } do { items = try await api.dayScheduleList(scenicId: scenicId, date: selectedDate.scheduleDayText) } catch { items = [] errorMessage = error.localizedDescription } } /// 删除排班后刷新当前日和月份标记。 func delete(item: ScheduleItem, api: any ScheduleServing, scenicId: Int?) async { do { try await api.deleteSchedule(id: item.id) await reload(api: api, scenicId: scenicId) } catch { errorMessage = error.localizedDescription } } /// 重置状态。 private func reset() { markedDays = [] items = [] loading = false } } /// 新增排班 ViewModel,负责可选订单加载、表单校验和提交。 @MainActor final class ScheduleAddViewModel { var onChange: (() -> Void)? var draft = ScheduleDraft() { didSet { onChange?() } } private(set) var availableOrders: [AvailableOrderResponse] = [] { didSet { onChange?() } } private(set) var loadingOrders = false { didSet { onChange?() } } private(set) var submitting = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } /// 加载可关联订单列表。 func loadOrders(api: any ScheduleServing, scenicId: Int?) async { guard let scenicId else { availableOrders = [] return } loadingOrders = true defer { loadingOrders = false } do { availableOrders = try await api.availableOrderList(scenicId: scenicId) } catch { availableOrders = [] errorMessage = error.localizedDescription } } /// 提交新增排班请求。 func submit(api: any ScheduleServing, scenicId: Int?, startDate: Date, endDate: Date) async -> Bool { guard !submitting else { return false } guard let scenicId else { return fail(.missingScenic) } let normalizedName = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedName.isEmpty else { return fail(.missingName) } guard endDate > startDate else { return fail(.invalidTime) } submitting = true errorMessage = nil defer { submitting = false } do { try await api.addSchedule( AddScheduleRequest( scenicId: "\(scenicId)", name: normalizedName, remark: draft.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? normalizedName : draft.remark.trimmingCharacters(in: .whitespacesAndNewlines), startTime: startDate.scheduleHourMinuteText, endTime: endDate.scheduleHourMinuteText, scheduleDate: startDate.scheduleDayText, orderNumber: draft.finalOrderNumber() ) ) return true } catch { errorMessage = normalizedError(error) return false } } /// 根据旧后端英文错误归一化为中文。 private func normalizedError(_ error: Error) -> String { let text = error.localizedDescription if text.contains("start time") || text.contains("format H:i") { return "开始时间格式不正确,请重新选择开始时间" } if text.contains("end time") { return "结束时间格式不正确,请重新选择结束时间" } return text } /// 记录校验失败并返回 false。 private func fail(_ error: ScheduleValidationError) -> Bool { errorMessage = error.localizedDescription return false } } extension Date { /// 当前月份第一天。 static var scheduleFirstDayOfMonth: Date { Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date() } /// yyyy-MM 格式的月份文本。 var scheduleYearMonthText: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy-MM" return formatter.string(from: self) } /// yyyy-MM-dd 格式的日期文本。 var scheduleDayText: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy-MM-dd" return formatter.string(from: self) } /// HH:mm 格式的时间文本。 var scheduleHourMinuteText: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "HH:mm" return formatter.string(from: self) } /// 当前月份的全部日期。 var scheduleDaysInMonth: [Date] { let calendar = Calendar.current let range = calendar.range(of: .day, in: .month, for: self) ?? 1..<2 let components = calendar.dateComponents([.year, .month], from: self) return range.compactMap { day in var next = components next.day = day return calendar.date(from: next) } } }