From c4de3d17d0202e02a996bb8c0d9d3d45d585a4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 29 Jun 2026 13:36:27 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=AE=A2=E5=8D=95=20Tab=20?= =?UTF-8?q?=E5=86=85=E9=97=A8=E5=BA=97=E4=B8=8E=E6=A0=B8=E9=94=80=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=AF=A6=E6=83=85=E9=A1=B5=E5=8F=8A=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表卡片仅展示摘要,保留订单来源绑定与押金订单详情等其它子流程。 Co-authored-by: Cursor --- .../App/Navigation/NavigationRouter.swift | 4 - .../Features/Orders/Models/OrderModels.swift | 56 --- suixinkan/Features/Orders/Orders.md | 15 +- .../ViewModels/OrderDetailViewModel.swift | 75 --- .../Orders/Views/OrderDetailViews.swift | 429 ------------------ .../Features/Orders/Views/OrdersView.swift | 17 - suixinkanTests/NavigationRouterTests.swift | 28 +- .../OrderDetailViewModelTests.swift | 169 ------- .../Orders/OrdersFlowUITests.swift | 38 -- 9 files changed, 11 insertions(+), 820 deletions(-) delete mode 100644 suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift delete mode 100644 suixinkan/Features/Orders/Views/OrderDetailViews.swift delete mode 100644 suixinkanTests/OrderDetailViewModelTests.swift diff --git a/suixinkan/App/Navigation/NavigationRouter.swift b/suixinkan/App/Navigation/NavigationRouter.swift index d761cdd..360cf1c 100644 --- a/suixinkan/App/Navigation/NavigationRouter.swift +++ b/suixinkan/App/Navigation/NavigationRouter.swift @@ -41,10 +41,6 @@ extension OrdersRoute { @ViewBuilder var destinationView: some View { switch self { - case .storeDetail(let item): - StoreOrderDetailView(item: item) - case .writeOffDetail(let item): - WriteOffOrderDetailView(item: item) case .depositDetail(let orderNumber): DepositOrderDetailView(orderNumber: orderNumber) case .depositShootingInfo(let orderNumber, let scenicSpotId, let photogUid): diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift index 5c015ed..fc80e8c 100644 --- a/suixinkan/Features/Orders/Models/OrderModels.swift +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -300,8 +300,6 @@ struct OrderRefundRequest: Encodable, Equatable { /// 订单详情路由实体,表示订单模块内部可 push 的二级页面。 enum OrdersRoute: Hashable { - case storeDetail(OrderEntity) - case writeOffDetail(WriteOffOrderItem) case depositDetail(orderNumber: String) case depositShootingInfo(orderNumber: String, scenicSpotId: Int, photogUid: Int) case historicalShooting(orderNumber: String) @@ -672,60 +670,6 @@ struct MultiTravelUploadFileItem: Encodable, Equatable { } } -/// 订单详情展示适配实体,统一列表摘要和详情接口响应的读取方式。 -struct StoreOrderDetailDisplay: Equatable { - let orderNumber: String - let orderTypeLabel: String - let createdAt: String - let orderStatusName: String - let actualPayAmount: String - let actualRefundAmount: String - let payTypeName: String - let payTime: String - let completeTime: String - let userId: Int - let phone: String - let projectId: Int - let projectName: String - let remark: String - - /// 使用订单列表项构建兜底展示数据。 - init(item: OrderEntity) { - orderNumber = item.orderNumber - orderTypeLabel = item.orderTypeLabel - createdAt = item.createdAt - orderStatusName = item.orderStatusName - actualPayAmount = item.actualPayAmount - actualRefundAmount = item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount - payTypeName = item.payTypeName - payTime = item.payTime - completeTime = item.completeTime - userId = item.userId - phone = item.phone - projectId = item.projectId - projectName = item.projectName - remark = item.remark - } - - /// 使用详情接口响应构建完整展示数据。 - init(detail: StoreOrderDetailResponse) { - orderNumber = detail.orderNumber - orderTypeLabel = detail.orderTypeLabel - createdAt = detail.createdAt - orderStatusName = detail.orderStatusName - actualPayAmount = detail.actualPayAmount - actualRefundAmount = detail.actualRefundAmount - payTypeName = detail.payTypeName - payTime = detail.payTime - completeTime = detail.completeTime - userId = detail.userId - phone = detail.phone - projectId = detail.projectId - projectName = detail.projectName - remark = detail.remark - } -} - /// 订单号解析器,负责从扫码内容或手动输入中提取可核销订单号。 enum OrderNumberParser { /// 从原始文本中解析订单号,兼容 URL、键值格式和纯订单号。 diff --git a/suixinkan/Features/Orders/Orders.md b/suixinkan/Features/Orders/Orders.md index 72d4135..fc7ee2c 100644 --- a/suixinkan/Features/Orders/Orders.md +++ b/suixinkan/Features/Orders/Orders.md @@ -2,7 +2,9 @@ ## 模块职责 -Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传、尾片入口,以及 orderType 11/14 的订单来源(带客单绑定)闭环。 +Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、扫码核销、手动核销、押金核销、押金退款、历史拍摄、任务上传、尾片入口,以及 orderType 11/14 的订单来源(带客单绑定)闭环。 + +订单 Tab 内的门店订单详情页与核销订单详情页暂未接入,列表卡片仅展示摘要信息。 当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。 @@ -10,31 +12,28 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销 - `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。 - `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。 -- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。 - `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。 - `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。 - `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。 -- `OrderRefundViewModel`:管理普通订单退款入口判断、全额/部分退款金额校验和提交保护。 +- `OrderRefundViewModel`:管理普通订单退款金额校验和提交保护(详情页接入前保留逻辑层)。 - `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。 - `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。 - `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。 - `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。 -- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。 +- `OrdersRoute`:表示订单模块二级页面路由,包含押金详情、历史拍摄、任务上传和尾片入口。 - `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。 ## 数据流程 订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentAppRole` 读取当前角色。景区管理员角色(`AppRoleCode.scenicAdmin`)使用景区管理员订单接口,其他角色使用摄影师订单接口。 -订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。 - -订单管理点击订单卡片进入订单详情。详情页优先用 `/api/app/store/order/detail` 按 `store_id + order_number` 拉取完整数据;缺少门店 ID 或接口失败时,保留列表卡片摘要作为兜底展示,并提示当前详情不完整。 +订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。列表卡片展示订单摘要,暂不支持点击进入详情页。 核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。 押金订单入口由首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片。 -普通订单退款只在 `orderStatus == 18 || orderStatus == 30`、`orderType != 19` 且可退金额大于 0 时展示入口。全额退款使用可退金额,部分退款要求金额大于 0、最多两位小数且不能超过可退金额。押金退款要求填写退款原因,普通退款和押金退款都不保存表单数据,提交成功后刷新对应订单数据。 +普通订单退款逻辑已保留在 `OrderRefundViewModel`,待详情页接入后再开放入口。 历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。 diff --git a/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift deleted file mode 100644 index f6c65e9..0000000 --- a/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// OrderDetailViewModel.swift -// suixinkan -// -// Created by Codex on 2026/6/22. -// - -import Foundation -import Combine - -@MainActor -/// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。 -final class OrderDetailViewModel: ObservableObject { - @Published private(set) var detail: StoreOrderDetailResponse? - @Published private(set) var loading = false - @Published private(set) var errorMessage: String? - @Published private(set) var contextMessage: String? - - /// 当前页面用于展示的订单信息,详情接口成功时优先使用详情数据。 - var display: StoreOrderDetailDisplay { - if let detail { - return StoreOrderDetailDisplay(detail: detail) - } - return StoreOrderDetailDisplay(item: item) - } - - /// 详情中的项目配置。 - var projectInfo: StoreOrderProjectInfo? { - detail?.multiTravel?.projectInfo - } - - /// 详情中的拍摄点列表。 - var shootingList: [StoreOrderShootingListItem] { - detail?.multiTravel?.shootingList ?? [] - } - - private let item: OrderEntity - - /// 初始化详情 ViewModel,并注入列表订单作为兜底数据。 - init(item: OrderEntity) { - self.item = item - } - - /// 加载门店订单详情,缺少门店 ID 时只保留列表摘要展示。 - func load(api: OrderServing, fallbackStoreId: Int?, forceReload: Bool = false) async { - guard !loading else { return } - if forceReload { - detail = nil - } else { - guard detail == nil else { return } - } - - guard let storeId = item.storeId ?? fallbackStoreId, storeId > 0 else { - contextMessage = "缺少门店上下文,当前展示订单摘要。" - return - } - - loading = true - errorMessage = nil - contextMessage = nil - defer { loading = false } - - do { - detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: item.orderNumber) - } catch { - errorMessage = error.localizedDescription - contextMessage = "详情加载失败,当前展示订单摘要。" - } - } - - /// 清理当前错误提示。 - func clearError() { - errorMessage = nil - } -} diff --git a/suixinkan/Features/Orders/Views/OrderDetailViews.swift b/suixinkan/Features/Orders/Views/OrderDetailViews.swift deleted file mode 100644 index 1c91f78..0000000 --- a/suixinkan/Features/Orders/Views/OrderDetailViews.swift +++ /dev/null @@ -1,429 +0,0 @@ -// -// OrderDetailViews.swift -// suixinkan -// -// Created by Codex on 2026/6/22. -// - -import SwiftUI - -/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。 -struct StoreOrderDetailView: View { - @EnvironmentObject private var accountContext: AccountContext - @Environment(\.ordersAPI) private var ordersAPI - @Environment(\.cooperationOrderAPI) private var cooperationOrderAPI - @EnvironmentObject private var router: RouterPath - @EnvironmentObject private var toastCenter: ToastCenter - @Environment(\.globalLoading) private var globalLoading - - let item: OrderEntity - @StateObject private var viewModel: OrderDetailViewModel - @StateObject private var refundViewModel = OrderRefundViewModel() - @State private var showRefundSheet = false - - /// 使用列表订单初始化详情页,并创建详情 ViewModel。 - init(item: OrderEntity) { - self.item = item - _viewModel = StateObject(wrappedValue: OrderDetailViewModel(item: item)) - } - - var body: some View { - List { - if let contextMessage = viewModel.contextMessage { - Section { - Label(contextMessage, systemImage: "info.circle") - .font(.system(size: AppMetrics.FontSize.footnote)) - .foregroundStyle(AppDesign.textSecondary) - } - } - - Section("订单信息") { - OrderDetailRow(title: "订单号", value: viewModel.display.orderNumber) - Button("复制订单号") { - UIPasteboard.general.string = viewModel.display.orderNumber - toastCenter.show("订单号已复制") - } - .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) - .foregroundStyle(AppDesign.primary) - OrderDetailRow(title: "订单状态", value: viewModel.display.orderStatusName) - OrderDetailRow(title: "订单类型", value: viewModel.display.orderTypeLabel) - OrderDetailRow(title: "创建时间", value: viewModel.display.createdAt) - OrderDetailRow(title: "付款时间", value: displayPayTime) - OrderDetailRow(title: "完成时间", value: viewModel.display.completeTime) - } - - Section("支付信息") { - OrderDetailRow(title: "付款金额", value: "¥\(emptyToZero(viewModel.display.actualPayAmount))") - OrderDetailRow(title: "退款金额", value: "¥\(emptyToZero(viewModel.display.actualRefundAmount))") - OrderDetailRow(title: "付款方式", value: viewModel.display.payTypeName) - OrderDetailRow(title: "用户 UID", value: "\(viewModel.display.userId)") - } - - Section("客户与项目") { - OrderDetailRow(title: "手机号", value: viewModel.display.phone) - OrderDetailRow(title: "关联项目", value: viewModel.display.projectName) - OrderDetailRow(title: "项目 ID", value: "\(viewModel.display.projectId)") - } - - if let projectInfo = viewModel.projectInfo { - Section("项目配置") { - OrderDetailRow(title: "打卡点数", value: "\(projectInfo.settleSpotNum)") - OrderDetailRow(title: "单点底片", value: "\(projectInfo.singleSpotMaterialNum)") - OrderDetailRow(title: "单点精修", value: "\(projectInfo.singleSpotPhotoNum)") - OrderDetailRow(title: "单点视频", value: "\(projectInfo.singleSpotVideoNum)") - } - } - - if let photoTravel = item.photoTravel { - Section("旅拍信息") { - OrderDetailRow(title: "核销时间", value: photoTravel.checkInTime) - OrderDetailRow(title: "修图张数", value: "\(photoTravel.orderPhotoNum + photoTravel.retouchGiftPhotoNum) 张") - OrderDetailRow(title: "视频剪辑", value: "\(photoTravel.orderVideoNum + photoTravel.retouchGiftVideoNum) 条") - } - } - - if !viewModel.shootingList.isEmpty { - Section("拍摄点") { - ForEach(viewModel.shootingList) { shooting in - ShootingPointRow(item: shooting) - } - } - } - - if !viewModel.display.remark.isEmpty { - Section("备注") { - Text(viewModel.display.remark) - .font(.system(size: AppMetrics.FontSize.subheadline)) - .foregroundStyle(AppDesign.textPrimary) - } - } - - Section("后续功能") { - Button { - router.navigate(to: .orders(.historicalShooting(orderNumber: viewModel.display.orderNumber))) - } label: { - navigationRow("历史拍摄") - } - if item.orderType == 19 { - Button { - router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: viewModel.display.orderNumber))) - } label: { - navigationRow("任务上传") - } - } - Button { - router.navigate(to: .orders(.orderTrailer(orderNumber: viewModel.display.orderNumber, title: "视频预告"))) - } label: { - navigationRow("视频预告") - } - Button { - router.navigate(to: .orders(.orderTrailer(orderNumber: viewModel.display.orderNumber, title: "尾片上传"))) - } label: { - navigationRow("尾片上传") - } - if refundViewModel.canRefund(item) { - Button { - refundViewModel.begin(item: item) - showRefundSheet = true - } label: { - navigationRow("退款") - } - } - } - } - .listStyle(.insetGrouped) - .scrollContentBackground(.hidden) - .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) - .navigationTitle("订单详情") - .navigationBarTitleDisplayMode(.inline) - .task { - await globalLoading.withLoading(message: "加载详情中...") { - await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id) - } - } - .alert("提示", isPresented: errorBinding) { - Button("知道了", role: .cancel) {} - } message: { - Text(viewModel.errorMessage ?? "") - } - .sheet(isPresented: $showRefundSheet) { - OrderRefundSheet( - item: item, - viewModel: refundViewModel, - onCancel: { showRefundSheet = false }, - onSubmit: { - Task { await submitRefund() } - } - ) - .presentationDetents([.medium, .large]) - } - } - - private var displayPayTime: String { - let payTime = viewModel.display.payTime - if payTime.isEmpty || payTime == "0" || payTime.hasPrefix("1970") { - return item.depositPayTime - } - return payTime - } - - private var errorBinding: Binding { - Binding( - get: { viewModel.errorMessage != nil }, - set: { visible in - if !visible { viewModel.clearError() } - } - ) - } - - /// 构建可跳转业务入口行。 - private func navigationRow(_ label: String) -> some View { - HStack { - Text(label) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) - .foregroundStyle(AppDesign.textSecondary) - } - } - - /// 提交普通订单退款并刷新详情。 - private func submitRefund() async { - let success = await refundViewModel.submit(api: ordersAPI, item: item) - guard success else { return } - if let referralOrderId = item.referralOrder?.id, referralOrderId > 0 { - Task { - try? await cooperationOrderAPI.saleUserUpdateReferralOrderStatus(id: referralOrderId, status: 3) - } - } - showRefundSheet = false - toastCenter.show("退款申请已提交") - await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id, forceReload: true) - } - - /// 将空金额转换为 0,避免详情页出现孤立货币符号。 - private func emptyToZero(_ value: String) -> String { - value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value - } -} - -/// 普通订单退款表单,负责收集退款模式、金额和原因。 -private struct OrderRefundSheet: View { - let item: OrderEntity - @ObservedObject var viewModel: OrderRefundViewModel - let onCancel: () -> Void - let onSubmit: () -> Void - @FocusState private var focusedField: RefundInputField? - - var body: some View { - NavigationStack { - Form { - Section("订单") { - OrderDetailRow(title: "订单号", value: item.orderNumber) - OrderDetailRow(title: "可退金额", value: "¥\(viewModel.availableAmountText(for: item))") - } - - Section("退款方式") { - Picker("退款方式", selection: $viewModel.mode) { - ForEach(OrderRefundMode.allCases) { mode in - Text(mode.title).tag(mode) - } - } - .pickerStyle(.segmented) - - if viewModel.mode == .partial { - TextField("请输入退款金额", text: $viewModel.amount) - .keyboardType(.decimalPad) - .focused($focusedField, equals: .amount) - } - } - - Section("退款原因") { - TextEditor(text: $viewModel.reason) - .frame(minHeight: 96) - .focused($focusedField, equals: .reason) - } - - if let errorMessage = viewModel.errorMessage { - Section { - Text(errorMessage) - .font(.system(size: AppMetrics.FontSize.footnote)) - .foregroundStyle(Color(hex: 0xEF4444)) - } - } - } - .navigationTitle("订单退款") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("取消", action: onCancel) - .disabled(viewModel.submitting) - } - ToolbarItem(placement: .confirmationAction) { - Button(viewModel.submitting ? "提交中..." : "提交", action: onSubmit) - .disabled(viewModel.submitting) - } - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("完成") { - focusedField = nil - } - } - } - } - } -} - -/// 退款表单当前聚焦字段。 -private enum RefundInputField: Hashable { - case amount - case reason -} - -/// 核销订单详情页,展示核销列表项信息并支持再次发起核销。 -struct WriteOffOrderDetailView: View { - @EnvironmentObject private var accountContext: AccountContext - @Environment(\.ordersAPI) private var ordersAPI - @EnvironmentObject private var toastCenter: ToastCenter - - let item: WriteOffOrderItem - @State private var verifying = false - @State private var showVerifyConfirmation = false - - var body: some View { - List { - Section("核销订单") { - OrderDetailRow(title: "订单号", value: item.orderNumber) - Button("复制订单号") { - UIPasteboard.general.string = item.orderNumber - toastCenter.show("订单号已复制") - } - .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) - .foregroundStyle(AppDesign.primary) - OrderDetailRow(title: "项目名称", value: item.projectName) - OrderDetailRow(title: "核销状态", value: displayVerificationStatus) - OrderDetailRow(title: "订单状态", value: item.orderStatusName) - OrderDetailRow(title: "订单金额", value: "¥\(item.orderAmount)") - OrderDetailRow(title: "手机号", value: item.userPhone) - OrderDetailRow(title: "支付时间", value: item.payTime) - OrderDetailRow(title: "核销时间", value: item.orderVerificationTime) - } - - Section { - Button(verifying ? "核销中..." : "确认核销") { - showVerifyConfirmation = true - } - .frame(maxWidth: .infinity, alignment: .center) - .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) - .foregroundStyle(.white) - .listRowBackground(canVerify ? AppDesign.primary : Color(hex: 0x9CA3AF)) - .disabled(!canVerify || verifying) - } - } - .listStyle(.insetGrouped) - .scrollContentBackground(.hidden) - .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) - .navigationTitle("核销订单详情") - .navigationBarTitleDisplayMode(.inline) - .confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) { - Button("确认核销") { - Task { await verify() } - } - Button("取消", role: .cancel) {} - } message: { - Text(""" - 订单号:\(item.orderNumber) - 项目:\(item.projectName.isEmpty ? "--" : item.projectName) - 金额:¥\(item.orderAmount) - """) - } - } - - private var displayVerificationStatus: String { - let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines) - return text.isEmpty ? "待核销" : text - } - - private var canVerify: Bool { - !displayVerificationStatus.contains("已核销") && - !displayVerificationStatus.contains("已退款") && - !displayVerificationStatus.contains("已完成") - } - - /// 调用核销接口并提示结果。 - private func verify() async { - guard !verifying else { return } - guard accountContext.currentScenic?.id != nil else { - toastCenter.show("缺少景区上下文") - return - } - - verifying = true - defer { verifying = false } - - do { - try await ordersAPI.writeOff(orderNumber: item.orderNumber) - toastCenter.show("核销成功") - } catch { - toastCenter.show(error.localizedDescription) - } - } -} - -/// 订单详情键值行,统一二级页面的字段排版。 -private struct OrderDetailRow: View { - let title: String - let value: String - - var body: some View { - HStack(alignment: .top, spacing: AppMetrics.Spacing.small) { - Text(title) - .font(.system(size: AppMetrics.FontSize.subheadline)) - .foregroundStyle(AppDesign.textSecondary) - .frame(width: 76, alignment: .leading) - Spacer(minLength: AppMetrics.Spacing.small) - Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "--" : value) - .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) - .foregroundStyle(AppDesign.textPrimary) - .multilineTextAlignment(.trailing) - } - } -} - -/// 拍摄点展示行,表示单个打卡点的拍摄人员、状态和评分。 -private struct ShootingPointRow: View { - let item: StoreOrderShootingListItem - - var body: some View { - VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { - HStack { - Text(title) - .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) - .foregroundStyle(AppDesign.textPrimary) - Spacer() - Text(statusText) - .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) - .foregroundStyle(item.status == 2 ? Color(hex: 0x22C55E) : AppDesign.primary) - } - OrderDetailRow(title: "摄影师", value: item.photogName) - OrderDetailRow(title: "评分", value: String(format: "%.1f", max(item.startAvg, item.start))) - } - .padding(.vertical, AppMetrics.Spacing.xxSmall) - } - - private var title: String { - let value = item.scenicSpotName.isEmpty ? item.staffName : item.scenicSpotName - return value.isEmpty ? "未命名打卡点" : value - } - - private var statusText: String { - switch item.status { - case 1: - return "待拍摄" - case 2: - return "已拍摄" - default: - return "未知" - } - } -} diff --git a/suixinkan/Features/Orders/Views/OrdersView.swift b/suixinkan/Features/Orders/Views/OrdersView.swift index 3b4bc95..5feb0fb 100644 --- a/suixinkan/Features/Orders/Views/OrdersView.swift +++ b/suixinkan/Features/Orders/Views/OrdersView.swift @@ -12,7 +12,6 @@ struct OrdersView: View { @EnvironmentObject private var accountContext: AccountContext @EnvironmentObject private var permissionContext: PermissionContext @EnvironmentObject private var appRouter: AppRouter - @EnvironmentObject private var router: RouterPath @Environment(\.ordersAPI) private var ordersAPI @Environment(\.cooperationOrderAPI) private var cooperationOrderAPI @EnvironmentObject private var toastCenter: ToastCenter @@ -489,10 +488,6 @@ struct OrdersView: View { .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) .background(.white, in: RoundedRectangle(cornerRadius: 8)) - .contentShape(Rectangle()) - .onTapGesture { - router.navigate(to: .orders(.storeDetail(item))) - } .accessibilityIdentifier("orders.store.card.\(item.orderNumber)") } @@ -519,17 +514,6 @@ struct OrdersView: View { .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) .foregroundStyle(AppDesign.primary) Spacer() - Button("详情") { - router.navigate(to: .orders(.writeOffDetail(item))) - } - .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)) { pendingVerifyOrder = item pendingVerifyOrderNumber = item.orderNumber @@ -825,7 +809,6 @@ private struct OrdersDateFilterSheet: View { .environmentObject(AccountContext()) .environmentObject(PermissionContext()) .environmentObject(AppRouter()) - .environmentObject(RouterPath()) .environment(\.ordersAPI, OrdersAPI(client: APIClient())) .environmentObject(ToastCenter()) } diff --git a/suixinkanTests/NavigationRouterTests.swift b/suixinkanTests/NavigationRouterTests.swift index 305ef56..3921740 100644 --- a/suixinkanTests/NavigationRouterTests.swift +++ b/suixinkanTests/NavigationRouterTests.swift @@ -16,8 +16,6 @@ final class NavigationRouterTests: XCTestCase { XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed) - XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed) XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed) } @@ -75,32 +73,14 @@ final class NavigationRouterTests: XCTestCase { } /// 测试订单模块真实路由可被压入导航栈。 - func testOrderRoutesCanBePushedIntoRouterPath() throws { + func testOrderRoutesCanBePushedIntoRouterPath() { let router = RouterPath() - let order = try Self.firstOrder() - let writeOff = try Self.firstWriteOffOrder() - router.navigate(to: .orders(.storeDetail(order))) - router.navigate(to: .orders(.writeOffDetail(writeOff))) router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) - XCTAssertEqual(router.path.count, 4) - XCTAssertEqual(router.path[0], .orders(.storeDetail(order))) - XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff))) - XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) - XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) - } - - /// 返回测试用订单管理列表项。 - private static func firstOrder() throws -> OrderEntity { - let payload = try TestFixture.payload(ListPayload.self, named: "order_v2_success") - return try XCTUnwrap(payload.list.first) - } - - /// 返回测试用核销订单列表项。 - private static func firstWriteOffOrder() throws -> WriteOffOrderItem { - let payload = try TestFixture.payload(ListPayload.self, named: "writeoff_list_success") - return try XCTUnwrap(payload.list.first) + XCTAssertEqual(router.path.count, 2) + XCTAssertEqual(router.path[0], .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) + XCTAssertEqual(router.path[1], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) } } diff --git a/suixinkanTests/OrderDetailViewModelTests.swift b/suixinkanTests/OrderDetailViewModelTests.swift deleted file mode 100644 index ae6ef94..0000000 --- a/suixinkanTests/OrderDetailViewModelTests.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// OrderDetailViewModelTests.swift -// suixinkanTests -// -// Created by Codex on 2026/6/22. -// - -import XCTest -@testable import suixinkan - -@MainActor -/// 订单详情 ViewModel 测试,覆盖详情加载、缺失上下文和错误兜底。 -final class OrderDetailViewModelTests: XCTestCase { - /// 测试有门店 ID 时请求详情接口并展示服务端字段。 - func testLoadRequestsDetailWhenStoreIdExists() async throws { - let item = try firstOrder() - let api = DetailMockOrderService() - api.detailResponses = [try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")] - let viewModel = OrderDetailViewModel(item: item) - - await viewModel.load(api: api, fallbackStoreId: 66) - - XCTAssertEqual(api.detailCalls.count, 1) - XCTAssertEqual(api.detailCalls[0].storeId, 66) - XCTAssertEqual(api.detailCalls[0].orderNumber, "ORDV2001") - XCTAssertEqual(viewModel.display.projectName, "亲子跟拍") - XCTAssertEqual(viewModel.projectInfo?.settleSpotNum, 3) - XCTAssertEqual(viewModel.shootingList.first?.scenicSpotName, "东门") - XCTAssertNil(viewModel.errorMessage) - } - - /// 测试缺少门店 ID 时不请求接口并保留列表摘要。 - func testLoadWithoutStoreIdUsesSummaryOnly() async throws { - let item = try firstOrder() - let api = DetailMockOrderService() - let viewModel = OrderDetailViewModel(item: item) - - await viewModel.load(api: api, fallbackStoreId: nil) - - XCTAssertTrue(api.detailCalls.isEmpty) - XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001") - XCTAssertEqual(viewModel.contextMessage, "缺少门店上下文,当前展示订单摘要。") - } - - /// 测试详情接口失败时保留摘要并暴露错误信息。 - func testLoadFailureKeepsSummaryAndExposesError() async throws { - let item = try firstOrder() - let api = DetailMockOrderService() - api.detailError = APIError.httpStatus(500, "server error") - let viewModel = OrderDetailViewModel(item: item) - - await viewModel.load(api: api, fallbackStoreId: 66) - - XCTAssertEqual(api.detailCalls.count, 1) - XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001") - XCTAssertEqual(viewModel.contextMessage, "详情加载失败,当前展示订单摘要。") - XCTAssertNotNil(viewModel.errorMessage) - } - - /// 测试详情模型兼容后端数字和字符串混合字段。 - func testDetailResponseDecodesMixedNumericAndStringFields() throws { - let detail = try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") - - XCTAssertEqual(detail.orderType, 19) - XCTAssertEqual(detail.actualRefundAmount, "0") - XCTAssertEqual(detail.userId, 7001) - XCTAssertEqual(detail.multiTravel?.projectInfo?.singleSpotVideoNum, 1) - XCTAssertEqual(detail.multiTravel?.shootingList.first?.startAvg, 4.8) - } - - /// 返回订单列表 fixture 中的第一条订单。 - private func firstOrder() throws -> OrderEntity { - let payload = try TestFixture.payload(ListPayload.self, named: "order_v2_success") - return try XCTUnwrap(payload.list.first) - } -} - -@MainActor -/// 订单详情服务测试替身,专门用于详情 ViewModel 测试。 -private final class DetailMockOrderService: OrderServing { - var detailResponses: [StoreOrderDetailResponse] = [] - var detailError: Error? - private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = [] - - /// 订单列表接口在详情测试中不会被调用。 - func orderList( - scenicId: Int, - page: Int, - pageSize: Int, - orderStatus: Int?, - userPhone: String?, - startTime: String?, - endTime: String?, - isRefined: Int?, - isScenicAdmin: Bool - ) async throws -> ListPayload { - XCTFail("orderList should not be called") - return ListPayload(total: 0, list: []) - } - - /// 核销列表接口在详情测试中不会被调用。 - func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { - XCTFail("writeOffList should not be called") - return ListPayload(total: 0, list: []) - } - - /// 核销接口在详情测试中不会被调用。 - func writeOff(orderNumber: String) async throws { - XCTFail("writeOff should not be called") - } - - /// 返回预置的订单详情响应。 - func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { - detailCalls.append((storeId: storeId, orderNumber: orderNumber)) - if let detailError { - throw detailError - } - return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() - } - - /// 押金订单列表接口在详情测试中不会被调用。 - func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { - XCTFail("depositOrderList should not be called") - return ListPayload(total: 0, list: []) - } - - /// 押金核销接口在详情测试中不会被调用。 - func depositOrderWriteOff(orderNumber: String) async throws { - XCTFail("depositOrderWriteOff should not be called") - } - - /// 押金退款接口在详情测试中不会被调用。 - func depositOrderRefund(orderNumber: String, refundReason: String) async throws { - XCTFail("depositOrderRefund should not be called") - } - - /// 拍摄信息接口在详情测试中不会被调用。 - func storeOrderShootingDetail( - storeId: Int, - orderNumber: String, - scenicSpotId: Int, - photogUid: Int - ) async throws -> StoreOrderShootingDetailResponse { - XCTFail("storeOrderShootingDetail should not be called") - return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") - } - - /// 普通退款接口在详情测试中不会被调用。 - func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { - XCTFail("orderRefund should not be called") - } - - /// 历史拍摄接口在详情测试中不会被调用。 - func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { - XCTFail("multiTravelShootHistory should not be called") - return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") - } - - /// 已核销打卡点接口在详情测试中不会被调用。 - func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { - XCTFail("multiTravelVerifiedScenicSpotList should not be called") - return [] - } - - /// 多点旅拍素材提交接口在详情测试中不会被调用。 - func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { - XCTFail("multiTravelUploadMaterial should not be called") - } -} diff --git a/suixinkanUITests/Orders/OrdersFlowUITests.swift b/suixinkanUITests/Orders/OrdersFlowUITests.swift index 5ad26d8..dcea0c8 100644 --- a/suixinkanUITests/Orders/OrdersFlowUITests.swift +++ b/suixinkanUITests/Orders/OrdersFlowUITests.swift @@ -6,44 +6,6 @@ import XCTest final class OrdersFlowUITests: AuthenticatedUITestCase { - /// 测试门店订单列表可进入详情页。 - func testStoreOrderDetailFlow() throws { - TabBarNavigator.select(.orders, app: app) - - app.application.staticTexts["订单管理"].tap() - app.waitForGlobalLoadingToDisappear() - - let firstOrderCard = app.application.otherElements - .matching(NSPredicate(format: "identifier BEGINSWITH 'orders.store.card.'")) - .firstMatch - if !firstOrderCard.waitForExistence(timeout: 10) { - throw XCTSkip("测试账号当前景区下暂无门店订单,跳过详情流测试。") - } - - firstOrderCard.tap() - NavigationAssertions.waitForNavigationTitle("订单详情", in: app) - BackNavigator.goBack(app: app) - } - - /// 测试核销订单列表可进入详情页。 - func testWriteOffOrderDetailFlow() throws { - TabBarNavigator.select(.orders, app: app) - - app.application.staticTexts["核销订单"].tap() - app.waitForGlobalLoadingToDisappear() - - let firstDetailButton = app.application.buttons - .matching(NSPredicate(format: "identifier BEGINSWITH 'orders.writeoff.detail.'")) - .firstMatch - if !firstDetailButton.waitForExistence(timeout: 10) { - throw XCTSkip("测试账号当前景区下暂无核销订单,跳过详情流测试。") - } - - firstDetailButton.tap() - NavigationAssertions.waitForNavigationTitle("核销订单详情", in: app) - BackNavigator.goBack(app: app) - } - /// 测试押金订单链路可进入列表与详情。 func testDepositOrderFlow() throws { app = try UITestSession.relaunch(openMenu: "押金订单详情")