// // ScheduleViews.swift // suixinkan // // Created by Codex on 2026/6/24. // import SwiftUI /// 排班管理页面,展示月视图日期标记和当天排班列表。 struct ScheduleManagementView: View { @Environment(AccountContext.self) private var accountContext @Environment(ScheduleAPI.self) private var scheduleAPI @Environment(\.globalLoading) private var globalLoading @State private var viewModel = ScheduleManagementViewModel() var body: some View { VStack(spacing: 0) { if accountContext.currentScenic?.id == nil { ContentUnavailableView("缺少景区", systemImage: "calendar.badge.exclamationmark", description: Text("请选择景区后再查看排班。")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { calendarCard scheduleListCard } .padding(AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .refreshable { await reload(showLoading: false) } } } .navigationTitle("日程管理") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { NavigationLink(value: HomeRoute.scheduleAdd) { Image(systemName: "plus") } .accessibilityLabel("添加日程") } } .task(id: accountContext.currentScenic?.id) { await reload(showLoading: true) } } private var calendarCard: some View { VStack(spacing: AppMetrics.Spacing.medium) { HStack { Button { Task { await viewModel.previousMonth(api: scheduleAPI, scenicId: accountContext.currentScenic?.id) } } label: { Image(systemName: "chevron.left") .frame(width: 36, height: 36) } Spacer() Text(viewModel.monthDate.scheduleYearMonthText) .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) Spacer() Button { Task { await viewModel.nextMonth(api: scheduleAPI, scenicId: accountContext.currentScenic?.id) } } label: { Image(systemName: "chevron.right") .frame(width: 36, height: 36) } } LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: AppMetrics.Spacing.small) { ForEach(viewModel.monthDate.scheduleDaysInMonth, id: \.scheduleDayText) { date in Button { Task { await viewModel.selectDate(date, api: scheduleAPI, scenicId: accountContext.currentScenic?.id) } } label: { VStack(spacing: 4) { Text(dayNumber(date)) .font(.system(size: AppMetrics.FontSize.subheadline, weight: viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? .bold : .regular)) Circle() .fill(viewModel.markedDays.contains(date.scheduleDayText) ? AppDesign.primary : .clear) .frame(width: 5, height: 5) } .foregroundStyle(viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? .white : AppDesign.textPrimary) .frame(height: 42) .frame(maxWidth: .infinity) .background(viewModel.selectedDate.scheduleDayText == date.scheduleDayText ? AppDesign.primary : Color.clear, in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private var scheduleListCard: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text(viewModel.selectedDate.scheduleDayText) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) if viewModel.items.isEmpty { ContentUnavailableView("当天暂无日程", systemImage: "calendar") .frame(minHeight: 180) } else { ForEach(viewModel.items) { item in ScheduleItemCard(item: item) { Task { await viewModel.delete(item: item, api: scheduleAPI, scenicId: accountContext.currentScenic?.id) } } } } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private func reload(showLoading: Bool) async { await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) { await viewModel.reload(api: scheduleAPI, scenicId: accountContext.currentScenic?.id) } } private func dayNumber(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "d" return formatter.string(from: date) } } /// 新增排班页面,支持手动录入和关联订单。 struct ScheduleAddView: View { @Environment(\.dismiss) private var dismiss @Environment(AccountContext.self) private var accountContext @Environment(ScheduleAPI.self) private var scheduleAPI @Environment(\.globalLoading) private var globalLoading @State private var viewModel = ScheduleAddViewModel() @State private var date = Date() @State private var startTime = Date() @State private var endTime = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date() var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { formCard("基础信息") { TextField("日程名称", text: $viewModel.draft.name) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) TextField("备注", text: $viewModel.draft.remark, axis: .vertical) .lineLimit(2...4) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 82) Picker("关联订单", selection: Binding(get: { viewModel.draft.selectedOrder?.orderNumber ?? "" }, set: { value in viewModel.draft.selectedOrder = viewModel.availableOrders.first { $0.orderNumber == value } })) { Text("不关联").tag("") ForEach(viewModel.availableOrders) { order in Text("\(order.projectName) \(order.orderNumber)").tag(order.orderNumber) } } TextField("手工输入订单号(兜底)", text: $viewModel.draft.manualOrderNumber) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) } formCard("时间") { DatePicker("日期", selection: $date, displayedComponents: .date) DatePicker("开始时间", selection: $startTime, displayedComponents: .hourAndMinute) DatePicker("结束时间", selection: $endTime, displayedComponents: .hourAndMinute) } if let error = viewModel.errorMessage { Text(error) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(Color(hex: 0xE5484D)) .frame(maxWidth: .infinity, alignment: .leading) } } .padding(AppMetrics.Spacing.medium) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("添加日程") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button(viewModel.submitting ? "保存中..." : "保存") { Task { await submit() } } .disabled(viewModel.submitting) } } .task { await viewModel.loadOrders(api: scheduleAPI, scenicId: accountContext.currentScenic?.id) } } private func formCard(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text(title) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) content() } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private func submit() async { let start = combinedDate(date: date, time: startTime) let end = combinedDate(date: date, time: endTime) await globalLoading.withLoading { if await viewModel.submit(api: scheduleAPI, scenicId: accountContext.currentScenic?.id, startDate: start, endDate: end) { dismiss() } } } private func combinedDate(date: Date, time: Date) -> Date { let calendar = Calendar.current var dateComponents = calendar.dateComponents([.year, .month, .day], from: date) let timeComponents = calendar.dateComponents([.hour, .minute], from: time) dateComponents.hour = timeComponents.hour dateComponents.minute = timeComponents.minute return calendar.date(from: dateComponents) ?? date } } /// 排班日程卡片。 private struct ScheduleItemCard: View { let item: ScheduleItem let onDelete: () -> Void var body: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { HStack { Text(item.name.isEmpty ? "--" : item.name) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) Spacer() Button(role: .destructive, action: onDelete) { Image(systemName: "trash") .frame(width: 32, height: 32) } } row("clock", "\(item.startTime) - \(item.endTime)") row("note.text", item.remark.isEmpty ? "无备注" : item.remark) if let orderNumber = item.orderNumber, !orderNumber.isEmpty { row("bag", "订单号:\(orderNumber)") } if let phone = item.userPhone, !phone.isEmpty { row("person", "用户:\(phone)") } } .padding(AppMetrics.Spacing.small) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) } private func row(_ icon: String, _ text: String) -> some View { HStack(spacing: 8) { Image(systemName: icon) .foregroundStyle(AppDesign.textSecondary) Text(text) .foregroundStyle(AppDesign.textSecondary) } .font(.system(size: AppMetrics.FontSize.caption)) } }