移除订单 Tab 内门店与核销订单详情页及跳转入口。
列表卡片仅展示摘要,保留订单来源绑定与押金订单详情等其它子流程。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -41,10 +41,6 @@ extension OrdersRoute {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var destinationView: some View {
|
var destinationView: some View {
|
||||||
switch self {
|
switch self {
|
||||||
case .storeDetail(let item):
|
|
||||||
StoreOrderDetailView(item: item)
|
|
||||||
case .writeOffDetail(let item):
|
|
||||||
WriteOffOrderDetailView(item: item)
|
|
||||||
case .depositDetail(let orderNumber):
|
case .depositDetail(let orderNumber):
|
||||||
DepositOrderDetailView(orderNumber: orderNumber)
|
DepositOrderDetailView(orderNumber: orderNumber)
|
||||||
case .depositShootingInfo(let orderNumber, let scenicSpotId, let photogUid):
|
case .depositShootingInfo(let orderNumber, let scenicSpotId, let photogUid):
|
||||||
|
|||||||
@ -300,8 +300,6 @@ struct OrderRefundRequest: Encodable, Equatable {
|
|||||||
|
|
||||||
/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。
|
/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。
|
||||||
enum OrdersRoute: Hashable {
|
enum OrdersRoute: Hashable {
|
||||||
case storeDetail(OrderEntity)
|
|
||||||
case writeOffDetail(WriteOffOrderItem)
|
|
||||||
case depositDetail(orderNumber: String)
|
case depositDetail(orderNumber: String)
|
||||||
case depositShootingInfo(orderNumber: String, scenicSpotId: Int, photogUid: Int)
|
case depositShootingInfo(orderNumber: String, scenicSpotId: Int, photogUid: Int)
|
||||||
case historicalShooting(orderNumber: String)
|
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 {
|
enum OrderNumberParser {
|
||||||
/// 从原始文本中解析订单号,兼容 URL、键值格式和纯订单号。
|
/// 从原始文本中解析订单号,兼容 URL、键值格式和纯订单号。
|
||||||
|
|||||||
@ -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`。
|
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
||||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||||
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
|
||||||
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
||||||
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。
|
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。
|
||||||
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。
|
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。
|
||||||
- `OrderRefundViewModel`:管理普通订单退款入口判断、全额/部分退款金额校验和提交保护。
|
- `OrderRefundViewModel`:管理普通订单退款金额校验和提交保护(详情页接入前保留逻辑层)。
|
||||||
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。
|
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。
|
||||||
- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。
|
- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。
|
||||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
||||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
||||||
- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。
|
- `OrdersRoute`:表示订单模块二级页面路由,包含押金详情、历史拍摄、任务上传和尾片入口。
|
||||||
- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。
|
- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。
|
||||||
|
|
||||||
## 数据流程
|
## 数据流程
|
||||||
|
|
||||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentAppRole` 读取当前角色。景区管理员角色(`AppRoleCode.scenicAdmin`)使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentAppRole` 读取当前角色。景区管理员角色(`AppRoleCode.scenicAdmin`)使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
||||||
|
|
||||||
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。
|
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。列表卡片展示订单摘要,暂不支持点击进入详情页。
|
||||||
|
|
||||||
订单管理点击订单卡片进入订单详情。详情页优先用 `/api/app/store/order/detail` 按 `store_id + order_number` 拉取完整数据;缺少门店 ID 或接口失败时,保留列表卡片摘要作为兜底展示,并提示当前详情不完整。
|
|
||||||
|
|
||||||
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
||||||
|
|
||||||
押金订单入口由首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片。
|
押金订单入口由首页 `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`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。
|
历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<Bool> {
|
|
||||||
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 "未知"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -12,7 +12,6 @@ struct OrdersView: View {
|
|||||||
@EnvironmentObject private var accountContext: AccountContext
|
@EnvironmentObject private var accountContext: AccountContext
|
||||||
@EnvironmentObject private var permissionContext: PermissionContext
|
@EnvironmentObject private var permissionContext: PermissionContext
|
||||||
@EnvironmentObject private var appRouter: AppRouter
|
@EnvironmentObject private var appRouter: AppRouter
|
||||||
@EnvironmentObject private var router: RouterPath
|
|
||||||
@Environment(\.ordersAPI) private var ordersAPI
|
@Environment(\.ordersAPI) private var ordersAPI
|
||||||
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||||
@EnvironmentObject private var toastCenter: ToastCenter
|
@EnvironmentObject private var toastCenter: ToastCenter
|
||||||
@ -489,10 +488,6 @@ struct OrdersView: View {
|
|||||||
.padding(AppMetrics.Spacing.medium)
|
.padding(AppMetrics.Spacing.medium)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
router.navigate(to: .orders(.storeDetail(item)))
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
|
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -519,17 +514,6 @@ struct OrdersView: View {
|
|||||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||||||
.foregroundStyle(AppDesign.primary)
|
.foregroundStyle(AppDesign.primary)
|
||||||
Spacer()
|
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)) {
|
Button(verifyButtonTitle(for: item)) {
|
||||||
pendingVerifyOrder = item
|
pendingVerifyOrder = item
|
||||||
pendingVerifyOrderNumber = item.orderNumber
|
pendingVerifyOrderNumber = item.orderNumber
|
||||||
@ -825,7 +809,6 @@ private struct OrdersDateFilterSheet: View {
|
|||||||
.environmentObject(AccountContext())
|
.environmentObject(AccountContext())
|
||||||
.environmentObject(PermissionContext())
|
.environmentObject(PermissionContext())
|
||||||
.environmentObject(AppRouter())
|
.environmentObject(AppRouter())
|
||||||
.environmentObject(RouterPath())
|
|
||||||
.environment(\.ordersAPI, OrdersAPI(client: APIClient()))
|
.environment(\.ordersAPI, OrdersAPI(client: APIClient()))
|
||||||
.environmentObject(ToastCenter())
|
.environmentObject(ToastCenter())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,6 @@ final class NavigationRouterTests: XCTestCase {
|
|||||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
||||||
XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed)
|
XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed)
|
||||||
XCTAssertTrue(AppRoute.home(.wallet).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(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed)
|
||||||
XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).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 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(.multiTravelTaskUpload(orderNumber: "MT001")))
|
||||||
router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
||||||
|
|
||||||
XCTAssertEqual(router.path.count, 4)
|
XCTAssertEqual(router.path.count, 2)
|
||||||
XCTAssertEqual(router.path[0], .orders(.storeDetail(order)))
|
XCTAssertEqual(router.path[0], .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
|
||||||
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
|
XCTAssertEqual(router.path[1], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
||||||
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<OrderEntity>.self, named: "order_v2_success")
|
|
||||||
return try XCTUnwrap(payload.list.first)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 返回测试用核销订单列表项。
|
|
||||||
private static func firstWriteOffOrder() throws -> WriteOffOrderItem {
|
|
||||||
let payload = try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")
|
|
||||||
return try XCTUnwrap(payload.list.first)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<OrderEntity>.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<OrderEntity> {
|
|
||||||
XCTFail("orderList should not be called")
|
|
||||||
return ListPayload(total: 0, list: [])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 核销列表接口在详情测试中不会被调用。
|
|
||||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
|
||||||
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<DepositOrderListItem> {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,44 +6,6 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class OrdersFlowUITests: AuthenticatedUITestCase {
|
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 {
|
func testDepositOrderFlow() throws {
|
||||||
app = try UITestSession.relaunch(openMenu: "押金订单详情")
|
app = try UITestSession.relaunch(openMenu: "押金订单详情")
|
||||||
|
|||||||
Reference in New Issue
Block a user