// // OrdersView.swift // suixinkan // // Created by Codex on 2026/6/22. // import SwiftUI /// 订单 Tab 根视图,展示订单管理和核销订单两个业务入口。 struct OrdersView: View { @Environment(AccountContext.self) private var accountContext @Environment(PermissionContext.self) private var permissionContext @Environment(AppRouter.self) private var appRouter @Environment(RouterPath.self) private var router @Environment(OrdersAPI.self) private var ordersAPI @Environment(ToastCenter.self) private var toastCenter @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var viewModel = OrdersViewModel() @State private var manualOrderNumber = "" @State private var showDateFilterSheet = false @State private var showVerifyConfirmation = false @State private var pendingVerifyOrderNumber: String? private var currentScenicId: Int? { accountContext.currentScenic?.id } private var currentRoleId: Int? { permissionContext.currentRole?.id } private var contentMaxWidth: CGFloat { horizontalSizeClass == .regular ? 900 : .infinity } private var currentFilterTitle: String { OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部" } private var timeFilterTitle: String { if let start = viewModel.filterStartDate, let end = viewModel.filterEndDate { return "\(Self.dateFormatter.string(from: start)) 至 \(Self.dateFormatter.string(from: end))" } return "时间筛选" } var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.small) { entrySection if currentScenicId == nil { ContentUnavailableView( "缺少经营上下文", systemImage: "mountain.2", description: Text("请先在首页选择景区后查看订单。") ) .frame(minHeight: 360) } else if viewModel.selectedEntry == .storeOrders { storeOrdersSection } else { writeOffSection } } .frame(maxWidth: contentMaxWidth) .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.top, AppMetrics.Spacing.medium) .padding(.bottom, AppMetrics.Spacing.pageVertical) .frame(maxWidth: .infinity) } .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) .navigationTitle("订单") .navigationBarTitleDisplayMode(.inline) .refreshable { await reload(showLoading: false) } .task { viewModel.selectedEntry = appRouter.selectedOrdersEntry await reload() } .onChange(of: appRouter.selectedOrdersEntry) { _, entry in guard viewModel.selectedEntry != entry else { return } viewModel.selectedEntry = entry Task { await reload() } } .onChange(of: accountContext.currentScenic?.id) { _, _ in Task { await reload() } } .confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) { Button("确认核销") { guard let orderNumber = pendingVerifyOrderNumber else { return } Task { await verify(orderNumber: orderNumber) } } Button("取消", role: .cancel) { pendingVerifyOrderNumber = nil } } message: { Text(pendingVerifyOrderNumber.map { "订单号:\($0)" } ?? "") } .sheet(isPresented: $showDateFilterSheet) { OrdersDateFilterSheet( startDate: $viewModel.filterStartDate, endDate: $viewModel.filterEndDate, onApply: { Task { await reload() } } ) } } private var entrySection: some View { VStack(spacing: AppMetrics.Spacing.small) { HStack(spacing: 0) { entryButton("订单管理", entry: .storeOrders) entryButton("核销订单", entry: .verificationOrders) } .padding(AppMetrics.Spacing.xxxSmall) .background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 8)) HStack(spacing: AppMetrics.Spacing.small) { contextPill( icon: viewModel.selectedEntry == .storeOrders ? "mountain.2" : "qrcode", title: viewModel.selectedEntry == .storeOrders ? "当前景区" : "核销订单", value: viewModel.selectedEntry == .storeOrders ? (accountContext.currentScenic?.name ?? "--") : "\(viewModel.writeOffTotal)" ) contextPill( icon: "doc.text", title: viewModel.selectedEntry == .storeOrders ? "订单总数" : "当前景区", value: viewModel.selectedEntry == .storeOrders ? "\(viewModel.storeTotal)" : (accountContext.currentScenic?.name ?? "--") ) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var storeOrdersSection: some View { VStack(spacing: AppMetrics.Spacing.small) { storeFilterSection storeList } } private var writeOffSection: some View { VStack(spacing: AppMetrics.Spacing.small) { writeOffActionSection writeOffList } } private var storeFilterSection: some View { VStack(spacing: AppMetrics.Spacing.small) { HStack(spacing: AppMetrics.Spacing.xSmall) { Menu { ForEach(OrderFilters.statusFilters) { filter in Button(filter.title) { viewModel.selectedStatus = filter.id Task { await reload() } } } } label: { filterLabel(title: currentFilterTitle, icon: "chevron.down") } Button { showDateFilterSheet = true } label: { filterLabel(title: timeFilterTitle, icon: "calendar") } .buttonStyle(.plain) } HStack(spacing: AppMetrics.Spacing.xSmall) { TextField("输入手机号搜索", text: $viewModel.searchPhone) .keyboardType(.phonePad) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .font(.system(size: AppMetrics.FontSize.subheadline)) .padding(.horizontal, AppMetrics.Spacing.small) .frame(height: 40) .background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6)) Button("搜索") { Task { await reload() } } .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, AppMetrics.Spacing.medium) .frame(height: 40) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 6)) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var storeList: some View { LazyVStack(spacing: AppMetrics.Spacing.small) { if viewModel.loading { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, AppMetrics.Spacing.xxLarge) } else if viewModel.storeOrders.isEmpty { ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。")) .frame(minHeight: 260) } else { ForEach(viewModel.storeOrders) { item in storeOrderCard(item) .onAppear { guard item.id == viewModel.storeOrders.last?.id else { return } Task { await loadMoreStoreOrders() } } } if viewModel.loadingMore { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, AppMetrics.Spacing.small) } } } } private var writeOffActionSection: some View { VStack(spacing: AppMetrics.Spacing.small) { Button { router.navigate(to: .placeholder(title: "扫码核销")) } label: { Label("扫码核销", systemImage: "qrcode.viewfinder") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 42) .background(Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .accessibilityIdentifier("orders.scan") HStack(spacing: AppMetrics.Spacing.xSmall) { TextField("手动输入订单号", text: $manualOrderNumber) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .font(.system(size: AppMetrics.FontSize.subheadline)) .padding(.horizontal, AppMetrics.Spacing.small) .frame(height: 42) .background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 8)) .accessibilityIdentifier("orders.manualOrderNumber") Button(viewModel.isVerifying ? "核销中" : "核销") { triggerManualVerify() } .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, AppMetrics.Spacing.medium) .frame(height: 42) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8)) .disabled(viewModel.isVerifying) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var writeOffList: some View { LazyVStack(spacing: AppMetrics.Spacing.small) { if viewModel.loading { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, AppMetrics.Spacing.xxLarge) } else if viewModel.writeOffOrders.isEmpty { ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。")) .frame(minHeight: 260) } else { ForEach(viewModel.writeOffOrders) { item in writeOffOrderCard(item) .onAppear { guard item.id == viewModel.writeOffOrders.last?.id else { return } Task { await loadMoreWriteOffOrders() } } } if viewModel.loadingMore { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, AppMetrics.Spacing.small) } } } } private func entryButton(_ title: String, entry: OrdersEntry) -> some View { let selected = viewModel.selectedEntry == entry return Button { switchEntry(entry) } label: { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium)) .foregroundStyle(selected ? AppDesign.primary : AppDesign.textSecondary) .frame(maxWidth: .infinity) .frame(height: 36) .background(selected ? .white : .clear, in: RoundedRectangle(cornerRadius: 6)) } .buttonStyle(.plain) } private func contextPill(icon: String, title: String, value: String) -> some View { HStack(spacing: AppMetrics.Spacing.xSmall) { Image(systemName: icon) .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(width: 28, height: 28) .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) { Text(title) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text(value) .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(1) .minimumScaleFactor(0.75) } Spacer(minLength: 0) } .padding(AppMetrics.Spacing.xSmall) .frame(maxWidth: .infinity) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) } private func filterLabel(title: String, icon: String) -> some View { HStack(spacing: AppMetrics.Spacing.xSmall) { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(Color(hex: 0x4B5563)) .lineLimit(1) .minimumScaleFactor(0.75) Spacer(minLength: AppMetrics.Spacing.xSmall) Image(systemName: icon) .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(Color(hex: 0x4B5563)) } .padding(.horizontal, AppMetrics.Spacing.small) .frame(maxWidth: .infinity) .frame(height: 38) .background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6)) } private func storeOrderCard(_ item: OrderEntity) -> some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) { Text("订单号:\(item.orderNumber)") .font(.system(size: AppMetrics.FontSize.title3)) .foregroundStyle(.black) .lineLimit(1) .minimumScaleFactor(0.72) Spacer() statusBadge(item.orderStatusName, color: statusColor(for: item.orderStatus)) } Text(item.createdAt.isEmpty ? "--" : item.createdAt) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) if !item.orderTypeLabel.isEmpty { Text(item.orderTypeLabel) .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(AppDesign.primary) .padding(.horizontal, AppMetrics.Spacing.xSmall) .padding(.vertical, AppMetrics.Spacing.xxSmall) .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 4)) } orderInfoRow("付款金额", "¥\(item.actualPayAmount)", valueColor: AppDesign.primary) orderInfoRow("付款时间", item.payTime.isEmpty ? "--" : item.payTime) orderInfoRow("完成时间", item.completeTime.isEmpty ? "--" : item.completeTime) orderInfoRow("付款方式", item.payTypeName.isEmpty ? "--" : item.payTypeName) orderInfoRow("手机号", maskPhoneNumber(item.phone)) orderInfoRow("关联项目", item.projectName.isEmpty ? "--" : item.projectName) } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) .background(.white, in: RoundedRectangle(cornerRadius: 8)) .contentShape(Rectangle()) .onTapGesture { router.navigate(to: .placeholder(title: "订单详情")) } .accessibilityIdentifier("orders.store.card.\(item.orderNumber)") } private func writeOffOrderCard(_ item: WriteOffOrderItem) -> some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) { Text("订单号:\(item.orderNumber)") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(.black) .lineLimit(1) Spacer() statusBadge(displayVerificationStatus(for: item), color: writeOffStatusColor(for: item)) } Text(item.payTime.isEmpty ? "--" : item.payTime) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) orderInfoRow("项目名称", item.projectName.isEmpty ? "--" : item.projectName) orderInfoRow("手机号", item.userPhone.isEmpty ? "--" : item.userPhone) orderInfoRow("订单状态", item.orderStatusName.isEmpty ? "--" : item.orderStatusName) HStack { Text("¥\(item.orderAmount)") .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) .foregroundStyle(AppDesign.primary) Spacer() Button("详情") { router.navigate(to: .placeholder(title: "核销订单详情")) } .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(AppDesign.primary) .padding(.horizontal, AppMetrics.Spacing.small) .frame(height: 32) .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8)) .buttonStyle(.plain) .accessibilityIdentifier("orders.writeoff.detail.\(item.orderNumber)") Button(verifyButtonTitle(for: item)) { pendingVerifyOrderNumber = item.orderNumber showVerifyConfirmation = true } .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, AppMetrics.Spacing.small) .frame(height: 32) .background(canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8)) .buttonStyle(.plain) .disabled(!canVerify(item) || viewModel.isVerifying) .accessibilityIdentifier("orders.writeoff.verify.\(item.orderNumber)") } } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private func orderInfoRow(_ title: String, _ value: String, valueColor: Color = .black) -> some View { HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(Color(hex: 0x4B5563)) .frame(width: 72, alignment: .leading) Spacer(minLength: AppMetrics.Spacing.xSmall) Text(value) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) .foregroundStyle(valueColor) .multilineTextAlignment(.trailing) .lineLimit(2) .minimumScaleFactor(0.75) } } private func statusBadge(_ text: String, color: Color) -> some View { Text(text.isEmpty ? "--" : text) .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(color) .padding(.horizontal, AppMetrics.Spacing.xSmall) .padding(.vertical, AppMetrics.Spacing.xxSmall) .background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4)) } private func switchEntry(_ entry: OrdersEntry) { guard viewModel.selectedEntry != entry else { return } viewModel.selectedEntry = entry appRouter.selectOrders(entry: entry) Task { await reload() } } private func reload(showLoading: Bool = true) async { do { try await viewModel.reload( api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading ) } catch { toastCenter.show(error.localizedDescription) } } private func loadMoreStoreOrders() async { do { try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId) } catch { toastCenter.show(error.localizedDescription) } } private func loadMoreWriteOffOrders() async { do { try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId) } catch { toastCenter.show(error.localizedDescription) } } private func triggerManualVerify() { let orderNumber = manualOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard !orderNumber.isEmpty else { toastCenter.show("请输入订单号") return } pendingVerifyOrderNumber = orderNumber showVerifyConfirmation = true } private func verify(orderNumber: String) async { guard let scenicId = currentScenicId else { return } do { try await viewModel.verify(api: ordersAPI, scenicId: scenicId, orderNumber: orderNumber) manualOrderNumber = "" pendingVerifyOrderNumber = nil toastCenter.show("核销成功") } catch { toastCenter.show(error.localizedDescription) } } private func canVerify(_ item: WriteOffOrderItem) -> Bool { let status = displayVerificationStatus(for: item) return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成") } private func displayVerificationStatus(for item: WriteOffOrderItem) -> String { let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? "待核销" : text } private func verifyButtonTitle(for item: WriteOffOrderItem) -> String { canVerify(item) ? "核销" : "已处理" } private func writeOffStatusColor(for item: WriteOffOrderItem) -> Color { canVerify(item) ? AppDesign.primary : Color(hex: 0x22C55E) } private func statusColor(for status: Int) -> Color { switch status { case 18: return AppDesign.primary case 30: return Color(hex: 0x22C55E) case 50: return Color(hex: 0xEF4444) default: return Color(hex: 0x6B7280) } } private func maskPhoneNumber(_ phone: String) -> String { let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.count >= 7 else { return trimmed.isEmpty ? "--" : trimmed } let prefix = trimmed.prefix(3) let suffix = trimmed.suffix(4) return "\(prefix)****\(suffix)" } private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" return formatter }() } /// 订单日期筛选 Sheet,负责选择开始和结束日期。 private struct OrdersDateFilterSheet: View { @Environment(\.dismiss) private var dismiss @Binding var startDate: Date? @Binding var endDate: Date? let onApply: () -> Void @State private var draftStartDate = Date() @State private var draftEndDate = Date() var body: some View { NavigationStack { Form { DatePicker("开始日期", selection: $draftStartDate, displayedComponents: .date) DatePicker("结束日期", selection: $draftEndDate, displayedComponents: .date) } .navigationTitle("时间筛选") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("清空") { startDate = nil endDate = nil onApply() dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("确定") { startDate = min(draftStartDate, draftEndDate) endDate = max(draftStartDate, draftEndDate) onApply() dismiss() } } } .onAppear { draftStartDate = startDate ?? Date() draftEndDate = endDate ?? Date() } } } } #Preview { NavigationStack { OrdersView() .environment(AccountContext()) .environment(PermissionContext()) .environment(AppRouter()) .environment(RouterPath()) .environment(OrdersAPI(client: APIClient())) .environment(ToastCenter()) } }