// // DepositOrderViews.swift // suixinkan // // Created by Codex on 2026/6/25. // import SwiftUI /// 押金订单入口页,支持订单号查询、列表分页、核销和退款。 struct DepositOrderEntryView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.ordersAPI) private var ordersAPI @EnvironmentObject private var router: RouterPath @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @StateObject private var viewModel = DepositOrderListViewModel() @State private var orderNumber = "" @State private var pendingWriteOff: DepositOrderListItem? @State private var pendingRefund: DepositOrderListItem? @State private var refundReason = "" var body: some View { List { Section("订单查询") { TextField("请输入押金订单号", text: $orderNumber) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) Button("查看订单详情") { navigateToDetail(orderNumber) } .disabled(orderNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Section { if viewModel.orders.isEmpty { AppContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass") .frame(maxWidth: .infinity, minHeight: 180) } else { ForEach(viewModel.orders) { item in DepositOrderRow( item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber, onDetail: { navigateToDetail(item.orderNumber) }, onWriteOff: { pendingWriteOff = item }, onRefund: { pendingRefund = item } ) } if viewModel.hasMore { Button(viewModel.loadingMore ? "加载中..." : "加载更多") { Task { await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: false) } } .frame(maxWidth: .infinity, alignment: .center) .disabled(viewModel.loadingMore) } } } header: { HStack { Text("押金订单列表") Spacer() Text("\(viewModel.orders.count)/\(viewModel.total)") } } } .listStyle(.insetGrouped) .scrollContentBackground(.hidden) .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) .navigationTitle("押金订单") .navigationBarTitleDisplayMode(.inline) .refreshable { await reload(showLoading: false) } .task { await reload(showLoading: true) } .confirmationDialog("确认核销该押金订单?", isPresented: writeOffBinding, titleVisibility: .visible) { Button("确认核销") { guard let pendingWriteOff else { return } Task { await writeOff(pendingWriteOff) } } Button("取消", role: .cancel) {} } message: { Text(pendingWriteOff?.orderNumber ?? "") } .alert("申请退款", isPresented: refundBinding) { TextField("请输入退款原因", text: $refundReason) Button("提交") { guard let pendingRefund else { return } Task { await refund(pendingRefund) } } Button("取消", role: .cancel) { pendingRefund = nil refundReason = "" } } message: { Text("请填写退款原因后提交。") } .alert("提示", isPresented: errorBinding) { Button("知道了", role: .cancel) {} } message: { Text(viewModel.errorMessage ?? "") } } private var writeOffBinding: Binding { Binding( get: { pendingWriteOff != nil }, set: { if !$0 { pendingWriteOff = nil } } ) } private var refundBinding: Binding { Binding( get: { pendingRefund != nil }, set: { if !$0 { pendingRefund = nil; refundReason = "" } } ) } private var errorBinding: Binding { Binding( get: { viewModel.errorMessage != nil }, set: { if !$0 { viewModel.clearError() } } ) } /// 跳转到押金订单详情页。 private func navigateToDetail(_ rawOrderNumber: String) { let text = rawOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } router.navigate(to: .orders(.depositDetail(orderNumber: text))) } /// 刷新押金订单列表。 private func reload(showLoading: Bool) async { if showLoading { await globalLoading.withLoading { await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: true) } } else { await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: true) } } /// 核销押金订单并提示结果。 private func writeOff(_ item: DepositOrderListItem) async { pendingWriteOff = nil let success = await viewModel.writeOff(api: ordersAPI, scenicId: accountContext.currentScenic?.id, orderNumber: item.orderNumber) if success { toastCenter.show("核销成功") } } /// 提交押金订单退款并提示结果。 private func refund(_ item: DepositOrderListItem) async { let reason = refundReason pendingRefund = nil refundReason = "" let success = await viewModel.refund(api: ordersAPI, scenicId: accountContext.currentScenic?.id, orderNumber: item.orderNumber, reason: reason) if success { toastCenter.show("退款申请已提交") } } } /// 押金订单列表行,展示订单状态和操作按钮。 private struct DepositOrderRow: View { let item: DepositOrderListItem let isOperating: Bool let onDetail: () -> Void let onWriteOff: () -> Void let onRefund: () -> Void var body: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { HStack { Text(item.orderNumber.isEmpty ? "--" : item.orderNumber) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Spacer() Text(item.statusName.isEmpty ? "状态\(item.status)" : item.statusName) .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(statusColor) .padding(.horizontal, AppMetrics.Spacing.small) .frame(height: 24) .background(statusColor.opacity(0.12), in: Capsule()) } HStack { Text("¥\(item.amount.isEmpty ? "0.00" : item.amount)") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.primary) Spacer() Text(item.createdAt.isEmpty ? "--" : item.createdAt) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } if !item.userPhone.isEmpty { Text("手机号:\(item.userPhone)") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } HStack(spacing: AppMetrics.Spacing.small) { rowButton("详情", tint: AppDesign.primary, action: onDetail) rowButton(isOperating ? "处理中..." : "核销", tint: Color(hex: 0x22C55E), disabled: isOperating, action: onWriteOff) rowButton(isOperating ? "处理中..." : "退款", tint: Color(hex: 0xF59E0B), disabled: isOperating, action: onRefund) } } .padding(.vertical, AppMetrics.Spacing.xSmall) } private var statusColor: Color { switch item.status { case 20: return AppDesign.primary case 30: return Color(hex: 0x22C55E) case 50: return Color(hex: 0xEF4444) default: return Color(hex: 0xF59E0B) } } /// 构建列表行操作按钮。 private func rowButton(_ title: String, tint: Color, disabled: Bool = false, action: @escaping () -> Void) -> some View { Button(action: action) { Text(title) .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(disabled ? AppDesign.textSecondary : tint) .frame(maxWidth: .infinity) .frame(height: 34) .background((disabled ? Color(hex: 0xEEF2F7) : tint.opacity(0.12)), in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) .disabled(disabled) } } /// 押金订单详情页,展示订单基础信息和拍摄点列表。 struct DepositOrderDetailView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.ordersAPI) private var ordersAPI @EnvironmentObject private var router: RouterPath @Environment(\.globalLoading) private var globalLoading let orderNumber: String @StateObject private var viewModel = DepositOrderDetailViewModel() var body: some View { List { if let detail = viewModel.detail { Section("订单信息") { OrderDetailValueRow(title: "订单号", value: detail.orderNumber) OrderDetailValueRow(title: "订单状态", value: detail.orderStatusName) OrderDetailValueRow(title: "订单类型", value: detail.orderTypeLabel) OrderDetailValueRow(title: "项目名称", value: detail.projectName) OrderDetailValueRow(title: "订单金额", value: "¥\(detail.actualPayAmount.isEmpty ? "0.00" : detail.actualPayAmount)") OrderDetailValueRow(title: "退款金额", value: "¥\(detail.actualRefundAmount.isEmpty ? "0" : detail.actualRefundAmount)") OrderDetailValueRow(title: "手机号", value: detail.phone) OrderDetailValueRow(title: "下单时间", value: detail.createdAt) OrderDetailValueRow(title: "付款时间", value: detail.payTime) OrderDetailValueRow(title: "完成时间", value: detail.completeTime) } if let projectInfo = detail.multiTravel?.projectInfo { Section("项目配置") { OrderDetailValueRow(title: "打卡点数", value: "\(projectInfo.settleSpotNum)") OrderDetailValueRow(title: "单点底片", value: "\(projectInfo.singleSpotMaterialNum)") OrderDetailValueRow(title: "单点精修", value: "\(projectInfo.singleSpotPhotoNum)") OrderDetailValueRow(title: "单点视频", value: "\(projectInfo.singleSpotVideoNum)") } } Section("拍摄详情") { let shootingList = detail.multiTravel?.shootingList ?? [] if shootingList.isEmpty { AppContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack") } else { ForEach(shootingList) { item in Button { router.navigate( to: .orders( .depositShootingInfo( orderNumber: detail.orderNumber, scenicSpotId: item.scenicSpotId, photogUid: item.photogUid ) ) ) } label: { DepositShootingPointRow(item: item) } } } } } else { Section { AppContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass") .frame(maxWidth: .infinity, minHeight: 220) } } } .listStyle(.insetGrouped) .scrollContentBackground(.hidden) .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) .navigationTitle("押金订单详情") .navigationBarTitleDisplayMode(.inline) .task { await globalLoading.withLoading { await viewModel.load(api: ordersAPI, storeId: accountContext.currentStore?.id, orderNumber: orderNumber) } } .alert("提示", isPresented: errorBinding) { Button("知道了", role: .cancel) {} } message: { Text(viewModel.errorMessage ?? "") } } private var errorBinding: Binding { Binding( get: { viewModel.errorMessage != nil }, set: { if !$0 { viewModel.clearError() } } ) } } /// 押金订单拍摄点行。 private struct DepositShootingPointRow: View { let item: StoreOrderShootingListItem var body: some View { HStack { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Text(item.photogName.isEmpty ? "摄影师未分配" : item.photogName) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Spacer() VStack(alignment: .trailing, spacing: AppMetrics.Spacing.xSmall) { Text(item.status == 2 ? "已拍摄" : "待拍摄") .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(item.status == 2 ? Color(hex: 0x22C55E) : AppDesign.primary) Text(String(format: "评分 %.1f", max(item.startAvg, item.start))) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } Image(systemName: "chevron.right") .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(AppDesign.textSecondary) } } private var title: String { let text = item.scenicSpotName.isEmpty ? item.staffName : item.scenicSpotName return text.isEmpty ? "未命名打卡点" : text } } /// 押金拍摄信息页,展示评价、底片和成片。 struct DepositOrderShootingInfoView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.ordersAPI) private var ordersAPI @Environment(\.globalLoading) private var globalLoading let orderNumber: String let scenicSpotId: Int let photogUid: Int @StateObject private var viewModel = DepositOrderShootingInfoViewModel() @State private var selectedTab: DepositShootingMediaTab = .negative var body: some View { List { if let detail = viewModel.detail { Section("拍摄点") { OrderDetailValueRow(title: "名称", value: detail.scenicSpotName) OrderDetailValueRow(title: "订单类型", value: detail.orderTypeName) } Section("评分与评价") { if let comment = detail.orderComment { RatingRow(title: "拍摄满意度", score: comment.starShooting) RatingRow(title: "修图满意度", score: comment.starRetouching) RatingRow(title: "景区满意度", score: comment.starScenery) RatingRow(title: "服务满意度", score: comment.starService) RatingRow(title: "相机满意度", score: comment.starCamera) Text(comment.content.isEmpty ? "暂无评价内容" : comment.content) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) } else { AppContentUnavailableView("暂无评价信息", systemImage: "star") } } Section { Picker("媒体类型", selection: $selectedTab) { Text("底片").tag(DepositShootingMediaTab.negative) Text("成片").tag(DepositShootingMediaTab.complete) } .pickerStyle(.segmented) OrderMediaGrid(files: selectedTab == .negative ? detail.materialList : detail.completeList) } header: { Text("媒体文件") } } else { Section { AppContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack") .frame(maxWidth: .infinity, minHeight: 220) } } } .listStyle(.insetGrouped) .scrollContentBackground(.hidden) .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) .navigationTitle("拍摄信息") .navigationBarTitleDisplayMode(.inline) .task { await globalLoading.withLoading { await viewModel.load( api: ordersAPI, storeId: accountContext.currentStore?.id, orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid ) } } .alert("提示", isPresented: errorBinding) { Button("知道了", role: .cancel) {} } message: { Text(viewModel.errorMessage ?? "") } } private var errorBinding: Binding { Binding( get: { viewModel.errorMessage != nil }, set: { if !$0 { viewModel.clearError() } } ) } } /// 历史拍摄信息页,展示多点位拍摄媒体。 struct HistoricalShootingInfoView: View { @Environment(\.ordersAPI) private var ordersAPI @Environment(\.globalLoading) private var globalLoading let orderNumber: String @StateObject private var viewModel = HistoricalShootingInfoViewModel() var body: some View { List { Section("订单") { OrderDetailValueRow(title: "订单号", value: orderNumber) OrderDetailValueRow(title: "项目名称", value: viewModel.projectName) OrderDetailValueRow(title: "项目类型", value: viewModel.projectTypeName) } Section("历史拍摄") { if viewModel.spots.isEmpty { AppContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle") .frame(maxWidth: .infinity, minHeight: 220) } else { ForEach(viewModel.spots) { spot in VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { HStack { Label(spot.scenicSpotName.isEmpty ? "未命名打卡点" : spot.scenicSpotName, systemImage: "mappin.and.ellipse") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(AppDesign.primary) Spacer() Text("摄影师:\(spot.photographerDisplayName)") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) } OrderMediaGrid(files: spot.files) } .padding(.vertical, AppMetrics.Spacing.xSmall) } } } } .listStyle(.insetGrouped) .scrollContentBackground(.hidden) .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) .navigationTitle("历史拍摄") .navigationBarTitleDisplayMode(.inline) .task { await globalLoading.withLoading { await viewModel.load(api: ordersAPI, orderNumber: orderNumber) } } .alert("提示", isPresented: errorBinding) { Button("知道了", role: .cancel) {} } message: { Text(viewModel.errorMessage ?? "") } } private var errorBinding: Binding { Binding( get: { viewModel.errorMessage != nil }, set: { if !$0 { viewModel.clearError() } } ) } } /// 拍摄媒体类型。 private enum DepositShootingMediaTab: Hashable { case negative case complete } /// 通用订单详情键值行。 private struct OrderDetailValueRow: View { let title: String let value: String var body: some View { HStack(alignment: .top) { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) Spacer(minLength: AppMetrics.Spacing.medium) Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "--" : value) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) .foregroundStyle(AppDesign.textPrimary) .multilineTextAlignment(.trailing) } } } /// 评分展示行。 private struct RatingRow: View { let title: String let score: Int var body: some View { HStack { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) Spacer() Text(String(repeating: "★", count: max(score, 0))) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(Color(hex: 0xF59E0B)) } } } /// 订单媒体网格,统一展示图片和视频缩略图。 private struct OrderMediaGrid: View { let files: [OrderMediaFile] private let columns = [ GridItem(.flexible(), spacing: AppMetrics.Spacing.small), GridItem(.flexible(), spacing: AppMetrics.Spacing.small), GridItem(.flexible(), spacing: AppMetrics.Spacing.small) ] var body: some View { if files.isEmpty { AppContentUnavailableView("暂无媒体文件", systemImage: "photo") .frame(maxWidth: .infinity, minHeight: 140) } else { LazyVGrid(columns: columns, spacing: AppMetrics.Spacing.small) { ForEach(files) { file in Link(destination: URL(string: file.fileUrl) ?? APIEnvironment.production.baseURL) { ZStack(alignment: .bottomTrailing) { RemoteImage(urlString: file.previewURLString) { RoundedRectangle(cornerRadius: 8) .fill(AppDesign.primarySoft) } .frame(height: 88) .clipShape(RoundedRectangle(cornerRadius: 8)) if file.isVideo { Image(systemName: "play.circle.fill") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.white) .shadow(radius: 2) .padding(AppMetrics.Spacing.xSmall) } } } .buttonStyle(.plain) } } } } }