同步订单详情与扫码核销流程
This commit is contained in:
@ -344,6 +344,7 @@
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@ -381,6 +382,7 @@
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
|
||||
@ -13,6 +13,7 @@ enum AppRoute: Hashable {
|
||||
case placeholder(title: String)
|
||||
case home(HomeRoute)
|
||||
case profile(ProfileRoute)
|
||||
case orders(OrdersRoute)
|
||||
|
||||
/// 返回该路由通过 NavigationStack push 后是否隐藏底部 TabBar。
|
||||
var hidesTabBarWhenPushed: Bool {
|
||||
@ -29,6 +30,21 @@ enum AppRoute: Hashable {
|
||||
route.destinationView
|
||||
case .profile(let route):
|
||||
route.destinationView
|
||||
case .orders(let route):
|
||||
route.destinationView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OrdersRoute {
|
||||
/// 构建订单模块二级路由对应的目标页面。
|
||||
@ViewBuilder
|
||||
var destinationView: some View {
|
||||
switch self {
|
||||
case .storeDetail(let item):
|
||||
StoreOrderDetailView(item: item)
|
||||
case .writeOffDetail(let item):
|
||||
WriteOffOrderDetailView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,10 +25,13 @@ protocol OrderServing {
|
||||
) async throws -> ListPayload<OrderEntity>
|
||||
|
||||
/// 获取核销订单列表。
|
||||
func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem>
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem>
|
||||
|
||||
/// 核销指定订单号。
|
||||
func writeOff(orderNumber: String) async throws
|
||||
|
||||
/// 获取门店订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -85,16 +88,21 @@ final class OrdersAPI: OrderServing {
|
||||
}
|
||||
|
||||
/// 获取核销订单列表。
|
||||
func writeOffList(scenicId: Int, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/order-verification-list",
|
||||
queryItems: [
|
||||
func writeOffList(scenicId: Int, storeId: Int? = nil, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let storeId, storeId > 0 {
|
||||
queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/order-verification-list",
|
||||
queryItems: queryItems
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -109,4 +117,18 @@ final class OrdersAPI: OrderServing {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取门店订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/order/detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "store_id", value: "\(storeId)"),
|
||||
URLQueryItem(name: "order_number", value: orderNumber)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ enum OrderFilters {
|
||||
}
|
||||
|
||||
/// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。
|
||||
struct OrderEntity: Decodable, Identifiable, Equatable {
|
||||
struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { orderNumber }
|
||||
|
||||
let photogUid: Int
|
||||
@ -124,7 +124,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable {
|
||||
}
|
||||
|
||||
/// 旅拍订单附加信息实体,表示修图、视频和核销相关数量。
|
||||
struct OrderPhotoTravel: Decodable, Equatable {
|
||||
struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
||||
let orderPhotoNum: Int
|
||||
let orderVideoNum: Int
|
||||
let checkInTime: String
|
||||
@ -151,7 +151,7 @@ struct OrderPhotoTravel: Decodable, Equatable {
|
||||
}
|
||||
|
||||
/// 核销订单列表实体,表示可核销订单卡片的展示和操作状态。
|
||||
struct WriteOffOrderItem: Decodable, Identifiable, Equatable {
|
||||
struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { orderNumber }
|
||||
|
||||
let orderNumber: String
|
||||
@ -197,6 +197,255 @@ struct WriteOffRequest: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。
|
||||
enum OrdersRoute: Hashable {
|
||||
case storeDetail(OrderEntity)
|
||||
case writeOffDetail(WriteOffOrderItem)
|
||||
}
|
||||
|
||||
/// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。
|
||||
struct StoreOrderDetailResponse: Decodable, Equatable, Hashable {
|
||||
let orderNumber: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let createdAt: String
|
||||
let orderStatus: Int
|
||||
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
|
||||
let multiTravel: StoreOrderMultiTravelInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case createdAt = "created_at"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case actualRefundAmount = "actual_refund_amount"
|
||||
case payTypeName = "pay_type_name"
|
||||
case payTime = "pay_time"
|
||||
case completeTime = "complete_time"
|
||||
case userId = "user_id"
|
||||
case phone
|
||||
case projectId = "project_id"
|
||||
case projectName = "project_name"
|
||||
case remark
|
||||
case multiTravel = "multi_travel"
|
||||
}
|
||||
|
||||
/// 宽松解码订单详情字段,兼容后端数字和字符串混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
|
||||
actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount)
|
||||
payTypeName = try container.decodeLossyString(forKey: .payTypeName)
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
completeTime = try container.decodeLossyString(forKey: .completeTime)
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
multiTravel = try container.decodeIfPresent(StoreOrderMultiTravelInfo.self, forKey: .multiTravel)
|
||||
}
|
||||
}
|
||||
|
||||
/// 多景点旅拍详情实体,表示项目配置和各拍摄点状态。
|
||||
struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable {
|
||||
let projectInfo: StoreOrderProjectInfo?
|
||||
let shootingList: [StoreOrderShootingListItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectInfo = "project_info"
|
||||
case shootingList = "shooting_list"
|
||||
}
|
||||
|
||||
/// 宽松解码多景点旅拍信息。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectInfo = try container.decodeIfPresent(StoreOrderProjectInfo.self, forKey: .projectInfo)
|
||||
shootingList = try container.decodeIfPresent([StoreOrderShootingListItem].self, forKey: .shootingList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单项目配置实体,表示打卡点数量和单点素材数量。
|
||||
struct StoreOrderProjectInfo: Decodable, Equatable, Hashable {
|
||||
let settleSpotNum: Int
|
||||
let singleSpotMaterialNum: Int
|
||||
let singleSpotPhotoNum: Int
|
||||
let singleSpotVideoNum: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case settleSpotNum = "settle_spot_num"
|
||||
case singleSpotMaterialNum = "single_spot_material_num"
|
||||
case singleSpotPhotoNum = "single_spot_photo_num"
|
||||
case singleSpotVideoNum = "single_spot_video_num"
|
||||
}
|
||||
|
||||
/// 宽松解码项目配置字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
settleSpotNum = try container.decodeLossyInt(forKey: .settleSpotNum) ?? 0
|
||||
singleSpotMaterialNum = try container.decodeLossyInt(forKey: .singleSpotMaterialNum) ?? 0
|
||||
singleSpotPhotoNum = try container.decodeLossyInt(forKey: .singleSpotPhotoNum) ?? 0
|
||||
singleSpotVideoNum = try container.decodeLossyInt(forKey: .singleSpotVideoNum) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 拍摄点状态实体,表示订单在某个打卡点的拍摄人员和完成情况。
|
||||
struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { "\(scenicSpotId)-\(photogUid)" }
|
||||
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
let scenicSpotName: String
|
||||
let staffName: String
|
||||
let photogName: String
|
||||
let status: Int
|
||||
let startAvg: Double
|
||||
let start: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case staffName = "staff_name"
|
||||
case photogName = "photog_name"
|
||||
case status
|
||||
case startAvg = "start_avg"
|
||||
case start
|
||||
}
|
||||
|
||||
/// 宽松解码拍摄点状态字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0
|
||||
photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0
|
||||
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
|
||||
staffName = try container.decodeLossyString(forKey: .staffName)
|
||||
photogName = try container.decodeLossyString(forKey: .photogName)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
startAvg = try container.decodeLossyDouble(forKey: .startAvg) ?? 0
|
||||
start = try container.decodeLossyDouble(forKey: .start) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单详情展示适配实体,统一列表摘要和详情接口响应的读取方式。
|
||||
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、键值格式和纯订单号。
|
||||
static func parse(_ raw: String) -> String? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return nil }
|
||||
|
||||
if let url = URL(string: text),
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let queryItems = components.queryItems,
|
||||
let value = queryItems.first(where: { $0.name.lowercased() == "order_number" })?.value,
|
||||
!value.isEmpty {
|
||||
return value
|
||||
}
|
||||
|
||||
let patterns = [
|
||||
"order[_-]?number[=:]([A-Za-z0-9_-]+)",
|
||||
"([A-Za-z0-9_-]{8,})"
|
||||
]
|
||||
for pattern in patterns {
|
||||
if let match = firstCapture(in: text, pattern: pattern) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
if !text.contains(" "), text.count >= 6 {
|
||||
return text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 返回第一个正则捕获组。
|
||||
private static func firstCapture(in text: String, pattern: String) -> String? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||||
return nil
|
||||
}
|
||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||
guard let match = regex.firstMatch(in: text, options: [], range: range),
|
||||
match.numberOfRanges > 1,
|
||||
let captureRange = Range(match.range(at: 1), in: text) else {
|
||||
return nil
|
||||
}
|
||||
return String(text[captureRange])
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将任意常见 JSON 值宽松解码成字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
@ -258,4 +507,18 @@ private extension KeyedDecodingContainer {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Double、Int 或数字字符串宽松解码成浮点数。
|
||||
func decodeLossyDouble(forKey key: Key) throws -> Double? {
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return Double(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,16 +2,19 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Orders 模块负责登录后的订单 Tab。当前第一轮同步覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新和手动核销闭环。
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销和手动核销闭环。
|
||||
|
||||
本轮不迁移扫码核销、退款、完整订单详情、历史拍摄、任务上传、视频预告等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。
|
||||
当前仍不迁移退款、历史拍摄、任务上传、视频预告、完整拍摄详情等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
||||
- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。
|
||||
- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。
|
||||
|
||||
## 数据流程
|
||||
|
||||
@ -19,8 +22,12 @@ Orders 模块负责登录后的订单 Tab。当前第一轮同步覆盖订单管
|
||||
|
||||
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。
|
||||
|
||||
核销订单支持手动输入订单号。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
||||
订单管理点击订单卡片进入订单详情。详情页优先用 `/api/app/store/order/detail` 按 `store_id + order_number` 拉取完整数据;缺少门店 ID 或接口失败时,保留列表卡片摘要作为兜底展示,并提示当前详情不完整。
|
||||
|
||||
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
||||
|
||||
## 路由边界
|
||||
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单。订单 Tab 内点击详情、扫码、退款等未迁移能力时进入占位页,避免入口崩溃。
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单。订单详情和核销订单详情已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。
|
||||
|
||||
退款、历史拍摄、任务上传、视频预告等尚未迁移的能力仍进入占位页,避免入口崩溃。
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
//
|
||||
// OrderDetailViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。
|
||||
final class OrderDetailViewModel {
|
||||
private(set) var detail: StoreOrderDetailResponse?
|
||||
private(set) var loading = false
|
||||
private(set) var errorMessage: String?
|
||||
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?) async {
|
||||
guard detail == nil, !loading 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
|
||||
}
|
||||
}
|
||||
@ -39,12 +39,12 @@ final class OrdersViewModel {
|
||||
}
|
||||
|
||||
/// 根据当前子入口刷新对应列表。
|
||||
func reload(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
switch selectedEntry {
|
||||
case .storeOrders:
|
||||
try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading)
|
||||
case .verificationOrders:
|
||||
try await reloadWriteOffOrders(api: api, scenicId: scenicId, showLoading: showLoading)
|
||||
try await reloadWriteOffOrders(api: api, scenicId: scenicId, storeId: storeId, showLoading: showLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,7 +103,7 @@ final class OrdersViewModel {
|
||||
}
|
||||
|
||||
/// 刷新核销订单列表,缺少景区时清空旧数据。
|
||||
func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, showLoading: Bool = true) async throws {
|
||||
func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
writeOffOrders = []
|
||||
writeOffTotal = 0
|
||||
@ -115,7 +115,7 @@ final class OrdersViewModel {
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let result = try await api.writeOffList(scenicId: scenicId, page: 1, pageSize: pageSize)
|
||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
|
||||
writeOffOrders = result.list
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = 1
|
||||
@ -123,13 +123,13 @@ final class OrdersViewModel {
|
||||
}
|
||||
|
||||
/// 加载核销订单下一页。
|
||||
func loadMoreWriteOffOrders(api: OrderServing, scenicId: Int?) async throws {
|
||||
func loadMoreWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil) async throws {
|
||||
guard !loadingMore, writeOffHasMore, let scenicId else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
|
||||
let nextPage = writeOffPage + 1
|
||||
let result = try await api.writeOffList(scenicId: scenicId, page: nextPage, pageSize: pageSize)
|
||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: nextPage, pageSize: pageSize)
|
||||
writeOffOrders.append(contentsOf: result.list)
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = nextPage
|
||||
@ -137,7 +137,7 @@ final class OrdersViewModel {
|
||||
}
|
||||
|
||||
/// 手动核销订单,并在成功后刷新核销列表。
|
||||
func verify(api: OrderServing, scenicId: Int, orderNumber: String) async throws {
|
||||
func verify(api: OrderServing, scenicId: Int, storeId: Int? = nil, orderNumber: String) async throws {
|
||||
guard !isVerifying else { return }
|
||||
|
||||
isVerifying = true
|
||||
@ -148,13 +148,31 @@ final class OrdersViewModel {
|
||||
}
|
||||
|
||||
try await api.writeOff(orderNumber: orderNumber)
|
||||
let refreshed = try await api.writeOffList(scenicId: scenicId, page: 1, pageSize: pageSize)
|
||||
let refreshed = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
|
||||
writeOffOrders = refreshed.list
|
||||
writeOffTotal = refreshed.total
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
|
||||
}
|
||||
|
||||
/// 解析扫码结果并返回当前核销列表中的匹配订单。
|
||||
func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? {
|
||||
guard let orderNumber = OrderNumberParser.parse(rawCode) else {
|
||||
return nil
|
||||
}
|
||||
let matched = writeOffOrders.first {
|
||||
$0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame
|
||||
}
|
||||
return (matched?.orderNumber ?? orderNumber, matched)
|
||||
}
|
||||
|
||||
/// 根据订单号查找当前核销列表中的订单。
|
||||
func writeOffOrder(matching orderNumber: String) -> WriteOffOrderItem? {
|
||||
writeOffOrders.first {
|
||||
$0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame
|
||||
}
|
||||
}
|
||||
|
||||
/// 用于测试重复提交场景的内部状态设置。
|
||||
func markVerifyingForTests(orderNumber: String) {
|
||||
isVerifying = true
|
||||
|
||||
330
suixinkan/Features/Orders/Views/OrderCodeScannerView.swift
Normal file
330
suixinkan/Features/Orders/Views/OrderCodeScannerView.swift
Normal file
@ -0,0 +1,330 @@
|
||||
//
|
||||
// OrderCodeScannerView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// 订单扫码错误实体,描述相机不可用、权限拒绝和元数据异常。
|
||||
enum OrderScannerError: LocalizedError {
|
||||
case cameraUnavailable
|
||||
case permissionDenied
|
||||
case invalidMetadata
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .cameraUnavailable:
|
||||
return "当前设备不可用相机,无法扫码。"
|
||||
case .permissionDenied:
|
||||
return "未开启相机权限,请在系统设置中允许后重试。"
|
||||
case .invalidMetadata:
|
||||
return "未识别到有效条码,请重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI 扫码页包装,提供标题、关闭按钮和底部扫码提示。
|
||||
struct OrderScannerPage: View {
|
||||
let onClose: () -> Void
|
||||
let onSuccess: (String) -> Void
|
||||
let onFailure: (Error) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
OrderCodeScannerView { result in
|
||||
switch result {
|
||||
case .success(let raw):
|
||||
onSuccess(raw)
|
||||
case .failure(let error):
|
||||
onFailure(error)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
Label("请将二维码/条码放入取景框", systemImage: "qrcode.viewfinder")
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||
.background(.black.opacity(0.55), in: Capsule())
|
||||
.padding(.bottom, AppMetrics.Spacing.xxLarge)
|
||||
}
|
||||
.navigationTitle("扫码核销")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("关闭") {
|
||||
onClose()
|
||||
}
|
||||
.accessibilityIdentifier("scanner.close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AVFoundation 扫码桥接视图,负责把相机识别结果回传给 SwiftUI。
|
||||
struct OrderCodeScannerView: UIViewControllerRepresentable {
|
||||
let onScanResult: (Result<String, Error>) -> Void
|
||||
|
||||
/// 创建底层扫码控制器。
|
||||
func makeUIViewController(context: Context) -> OrderScannerViewController {
|
||||
let controller = OrderScannerViewController()
|
||||
controller.onScanResult = onScanResult
|
||||
return controller
|
||||
}
|
||||
|
||||
/// 扫码控制器没有 SwiftUI 驱动的可变配置。
|
||||
func updateUIViewController(_ uiViewController: OrderScannerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
/// AVFoundation 扫码控制器,管理相机权限、预览层、识别类型和手电筒。
|
||||
final class OrderScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onScanResult: ((Result<String, Error>) -> Void)?
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var videoDevice: AVCaptureDevice?
|
||||
private var hasFinishedScan = false
|
||||
|
||||
private let scanFrameView: UIView = {
|
||||
let view = UIView()
|
||||
view.layer.borderColor = UIColor.systemBlue.cgColor
|
||||
view.layer.borderWidth = 2
|
||||
view.layer.cornerRadius = 12
|
||||
view.backgroundColor = .clear
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
return view
|
||||
}()
|
||||
|
||||
private let guideLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = "将二维码/条形码放入框内自动识别"
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
label.textAlignment = .center
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var torchButton: UIButton = {
|
||||
let button = Self.makeScannerButton(title: "手电筒", backgroundColor: UIColor.black.withAlphaComponent(0.42))
|
||||
button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var retryButton: UIButton = {
|
||||
let button = Self.makeScannerButton(title: "重新扫描", backgroundColor: UIColor.systemBlue.withAlphaComponent(0.85))
|
||||
button.addTarget(self, action: #selector(restartScan), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setupControls()
|
||||
checkPermissionAndStart()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.layer.bounds
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
updateTorch(enabled: false)
|
||||
stopSession()
|
||||
}
|
||||
|
||||
/// 检查相机权限,并在授权后启动扫码。
|
||||
private func checkPermissionAndStart() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
startSession()
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if granted {
|
||||
self.startSession()
|
||||
} else {
|
||||
self.onScanResult?(.failure(OrderScannerError.permissionDenied))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
onScanResult?(.failure(OrderScannerError.permissionDenied))
|
||||
@unknown default:
|
||||
onScanResult?(.failure(OrderScannerError.permissionDenied))
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动扫码会话。
|
||||
private func startSession() {
|
||||
hasFinishedScan = false
|
||||
guard !session.isRunning else { return }
|
||||
|
||||
do {
|
||||
try configureSession()
|
||||
} catch {
|
||||
onScanResult?(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止扫码会话。
|
||||
private func stopSession() {
|
||||
guard session.isRunning else { return }
|
||||
session.stopRunning()
|
||||
}
|
||||
|
||||
/// 创建扫码页底部操作按钮。
|
||||
private static func makeScannerButton(title: String, backgroundColor: UIColor) -> UIButton {
|
||||
var attributedTitle = AttributedString(title)
|
||||
attributedTitle.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.attributedTitle = attributedTitle
|
||||
configuration.baseForegroundColor = .white
|
||||
configuration.baseBackgroundColor = backgroundColor
|
||||
configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)
|
||||
configuration.cornerStyle = .capsule
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
button.configuration = configuration
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
return button
|
||||
}
|
||||
|
||||
/// 配置相机输入、元数据输出和预览层。
|
||||
private func configureSession() throws {
|
||||
if previewLayer != nil {
|
||||
return
|
||||
}
|
||||
|
||||
guard let videoDevice = AVCaptureDevice.default(for: .video) else {
|
||||
throw OrderScannerError.cameraUnavailable
|
||||
}
|
||||
self.videoDevice = videoDevice
|
||||
|
||||
let videoInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||
guard session.canAddInput(videoInput) else {
|
||||
throw OrderScannerError.cameraUnavailable
|
||||
}
|
||||
session.addInput(videoInput)
|
||||
|
||||
let metadataOutput = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(metadataOutput) else {
|
||||
throw OrderScannerError.invalidMetadata
|
||||
}
|
||||
session.addOutput(metadataOutput)
|
||||
|
||||
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
metadataOutput.metadataObjectTypes = [
|
||||
.qr,
|
||||
.ean8,
|
||||
.ean13,
|
||||
.pdf417,
|
||||
.code39,
|
||||
.code93,
|
||||
.code128,
|
||||
.dataMatrix,
|
||||
.aztec,
|
||||
.itf14,
|
||||
.upce
|
||||
]
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.layer.bounds
|
||||
view.layer.insertSublayer(preview, at: 0)
|
||||
previewLayer = preview
|
||||
updateTorchButtonState()
|
||||
}
|
||||
|
||||
/// 处理相机识别到的二维码或条形码内容。
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
guard !hasFinishedScan else { return }
|
||||
|
||||
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
let code = metadataObject.stringValue,
|
||||
!code.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
hasFinishedScan = true
|
||||
stopSession()
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
onScanResult?(.success(code))
|
||||
}
|
||||
|
||||
/// 布局扫码框、提示文本和操作按钮。
|
||||
private func setupControls() {
|
||||
view.addSubview(scanFrameView)
|
||||
view.addSubview(guideLabel)
|
||||
view.addSubview(torchButton)
|
||||
view.addSubview(retryButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scanFrameView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
scanFrameView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
|
||||
scanFrameView.widthAnchor.constraint(equalToConstant: 240),
|
||||
scanFrameView.heightAnchor.constraint(equalToConstant: 240),
|
||||
guideLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
||||
guideLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
||||
guideLabel.topAnchor.constraint(equalTo: scanFrameView.bottomAnchor, constant: 14),
|
||||
torchButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
|
||||
torchButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18),
|
||||
retryButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
|
||||
retryButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18)
|
||||
])
|
||||
}
|
||||
|
||||
/// 根据设备能力更新手电筒按钮状态。
|
||||
private func updateTorchButtonState() {
|
||||
let hasTorch = videoDevice?.hasTorch == true
|
||||
torchButton.isEnabled = hasTorch
|
||||
torchButton.alpha = hasTorch ? 1 : 0.45
|
||||
}
|
||||
|
||||
/// 切换手电筒开关。
|
||||
@objc
|
||||
private func toggleTorch() {
|
||||
let next = !(videoDevice?.isTorchActive ?? false)
|
||||
updateTorch(enabled: next)
|
||||
}
|
||||
|
||||
/// 设置手电筒状态。
|
||||
private func updateTorch(enabled: Bool) {
|
||||
guard let device = videoDevice, device.hasTorch else { return }
|
||||
do {
|
||||
try device.lockForConfiguration()
|
||||
if enabled {
|
||||
try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel)
|
||||
} else {
|
||||
device.torchMode = .off
|
||||
}
|
||||
device.unlockForConfiguration()
|
||||
torchButton.setTitle(enabled ? "关闭手电" : "手电筒", for: .normal)
|
||||
} catch {
|
||||
torchButton.setTitle("手电筒不可用", for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置扫描状态并重新启动识别。
|
||||
@objc
|
||||
private func restartScan() {
|
||||
hasFinishedScan = false
|
||||
updateTorch(enabled: false)
|
||||
startSession()
|
||||
}
|
||||
}
|
||||
314
suixinkan/Features/Orders/Views/OrderDetailViews.swift
Normal file
314
suixinkan/Features/Orders/Views/OrderDetailViews.swift
Normal file
@ -0,0 +1,314 @@
|
||||
//
|
||||
// OrderDetailViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。
|
||||
struct StoreOrderDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
|
||||
let item: OrderEntity
|
||||
@State private var viewModel: OrderDetailViewModel
|
||||
|
||||
/// 使用列表订单初始化详情页,并创建详情 ViewModel。
|
||||
init(item: OrderEntity) {
|
||||
self.item = item
|
||||
_viewModel = State(initialValue: OrderDetailViewModel(item: item))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if viewModel.loading {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("加载详情中...")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("后续功能") {
|
||||
placeholderButton("历史拍摄", title: "历史拍摄")
|
||||
placeholderButton("任务上传", title: "任务上传")
|
||||
placeholderButton("视频预告", title: "视频预告")
|
||||
placeholderButton("退款", title: "订单退款")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
.navigationTitle("订单详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id)
|
||||
}
|
||||
.alert("提示", isPresented: errorBinding) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
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 placeholderButton(_ label: String, title: String) -> some View {
|
||||
Button {
|
||||
router.navigate(to: .placeholder(title: title))
|
||||
} label: {
|
||||
HStack {
|
||||
Text(label)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
|
||||
/// 将空金额转换为 0,避免详情页出现孤立货币符号。
|
||||
private func emptyToZero(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单详情页,展示核销列表项信息并支持再次发起核销。
|
||||
struct WriteOffOrderDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var 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 "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,13 +20,21 @@ struct OrdersView: View {
|
||||
@State private var viewModel = OrdersViewModel()
|
||||
@State private var manualOrderNumber = ""
|
||||
@State private var showDateFilterSheet = false
|
||||
@State private var showScanner = false
|
||||
@State private var showVerifyConfirmation = false
|
||||
@State private var pendingVerifyOrder: WriteOffOrderItem?
|
||||
@State private var pendingVerifyOrderNumber: String?
|
||||
@State private var matchedScannedOrderNumber: String?
|
||||
@State private var scanHintMessage: String?
|
||||
|
||||
private var currentScenicId: Int? {
|
||||
accountContext.currentScenic?.id
|
||||
}
|
||||
|
||||
private var currentStoreId: Int? {
|
||||
accountContext.currentStore?.id
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
permissionContext.currentRole?.id
|
||||
}
|
||||
@ -47,6 +55,7 @@ struct OrdersView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scrollProxy in
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
entrySection
|
||||
@ -70,6 +79,13 @@ struct OrdersView: View {
|
||||
.padding(.bottom, AppMetrics.Spacing.pageVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.onChange(of: matchedScannedOrderNumber) { _, orderNumber in
|
||||
guard let orderNumber else { return }
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
scrollProxy.scrollTo(orderNumber, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
.navigationTitle("订单")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@ -92,10 +108,24 @@ struct OrdersView: View {
|
||||
Task { await verify(orderNumber: orderNumber) }
|
||||
}
|
||||
Button("取消", role: .cancel) {
|
||||
pendingVerifyOrder = nil
|
||||
pendingVerifyOrderNumber = nil
|
||||
}
|
||||
} message: {
|
||||
Text(pendingVerifyOrderNumber.map { "订单号:\($0)" } ?? "")
|
||||
Text(verifyDialogMessage)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showScanner) {
|
||||
OrderScannerPage(
|
||||
onClose: { showScanner = false },
|
||||
onSuccess: { rawCode in
|
||||
showScanner = false
|
||||
handleScanResult(rawCode)
|
||||
},
|
||||
onFailure: { error in
|
||||
showScanner = false
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showDateFilterSheet) {
|
||||
OrdersDateFilterSheet(
|
||||
@ -220,7 +250,7 @@ struct OrdersView: View {
|
||||
private var writeOffActionSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button {
|
||||
router.navigate(to: .placeholder(title: "扫码核销"))
|
||||
showScanner = true
|
||||
} label: {
|
||||
Label("扫码核销", systemImage: "qrcode.viewfinder")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
@ -232,6 +262,13 @@ struct OrdersView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("orders.scan")
|
||||
|
||||
if let scanHintMessage {
|
||||
Label(scanHintMessage, systemImage: "info.circle")
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
TextField("手动输入订单号", text: $manualOrderNumber)
|
||||
.textInputAutocapitalization(.never)
|
||||
@ -377,7 +414,7 @@ struct OrdersView: View {
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
router.navigate(to: .placeholder(title: "订单详情"))
|
||||
router.navigate(to: .orders(.storeDetail(item)))
|
||||
}
|
||||
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
|
||||
}
|
||||
@ -406,7 +443,7 @@ struct OrdersView: View {
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Spacer()
|
||||
Button("详情") {
|
||||
router.navigate(to: .placeholder(title: "核销订单详情"))
|
||||
router.navigate(to: .orders(.writeOffDetail(item)))
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
@ -417,6 +454,7 @@ struct OrdersView: View {
|
||||
.accessibilityIdentifier("orders.writeoff.detail.\(item.orderNumber)")
|
||||
|
||||
Button(verifyButtonTitle(for: item)) {
|
||||
pendingVerifyOrder = item
|
||||
pendingVerifyOrderNumber = item.orderNumber
|
||||
showVerifyConfirmation = true
|
||||
}
|
||||
@ -432,7 +470,12 @@ struct OrdersView: View {
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
.background(cardBackgroundColor(for: item), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(matchedScannedOrderNumber == item.orderNumber ? AppDesign.primary : Color.clear, lineWidth: 2)
|
||||
}
|
||||
.id(item.orderNumber)
|
||||
}
|
||||
|
||||
private func orderInfoRow(_ title: String, _ value: String, valueColor: Color = .black) -> some View {
|
||||
@ -472,6 +515,7 @@ struct OrdersView: View {
|
||||
try await viewModel.reload(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
storeId: currentStoreId,
|
||||
roleId: currentRoleId,
|
||||
showLoading: showLoading
|
||||
)
|
||||
@ -490,7 +534,7 @@ struct OrdersView: View {
|
||||
|
||||
private func loadMoreWriteOffOrders() async {
|
||||
do {
|
||||
try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId)
|
||||
try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId, storeId: currentStoreId)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
@ -502,27 +546,73 @@ struct OrdersView: View {
|
||||
toastCenter.show("请输入订单号")
|
||||
return
|
||||
}
|
||||
if let matched = viewModel.writeOffOrder(matching: orderNumber) {
|
||||
pendingVerifyOrder = matched
|
||||
pendingVerifyOrderNumber = matched.orderNumber
|
||||
matchedScannedOrderNumber = matched.orderNumber
|
||||
scanHintMessage = "已定位到列表订单:\(matched.orderNumber)"
|
||||
} else {
|
||||
pendingVerifyOrder = nil
|
||||
pendingVerifyOrderNumber = orderNumber
|
||||
matchedScannedOrderNumber = nil
|
||||
scanHintMessage = "未在当前列表定位到订单,将按输入订单号发起核销。"
|
||||
}
|
||||
showVerifyConfirmation = true
|
||||
}
|
||||
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
do {
|
||||
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, orderNumber: orderNumber)
|
||||
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, storeId: currentStoreId, orderNumber: orderNumber)
|
||||
manualOrderNumber = ""
|
||||
pendingVerifyOrder = nil
|
||||
pendingVerifyOrderNumber = nil
|
||||
matchedScannedOrderNumber = nil
|
||||
scanHintMessage = nil
|
||||
toastCenter.show("核销成功")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理扫码返回的原始内容,并进入二次确认核销流程。
|
||||
private func handleScanResult(_ rawCode: String) {
|
||||
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||
toastCenter.show("扫码成功,但未识别到有效订单号,请手动输入。")
|
||||
return
|
||||
}
|
||||
|
||||
manualOrderNumber = result.orderNumber
|
||||
pendingVerifyOrder = result.matched
|
||||
pendingVerifyOrderNumber = result.orderNumber
|
||||
|
||||
if let matched = result.matched {
|
||||
matchedScannedOrderNumber = matched.orderNumber
|
||||
scanHintMessage = "已定位到列表订单:\(matched.orderNumber)"
|
||||
} else {
|
||||
matchedScannedOrderNumber = nil
|
||||
scanHintMessage = "未在当前列表定位到订单,已填入订单号,可继续核销。"
|
||||
}
|
||||
showVerifyConfirmation = true
|
||||
}
|
||||
|
||||
private func canVerify(_ item: WriteOffOrderItem) -> Bool {
|
||||
let status = displayVerificationStatus(for: item)
|
||||
return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成")
|
||||
}
|
||||
|
||||
private var verifyDialogMessage: String {
|
||||
let orderNumber = pendingVerifyOrderNumber ?? "-"
|
||||
if let order = pendingVerifyOrder ?? viewModel.writeOffOrder(matching: orderNumber) {
|
||||
return """
|
||||
订单号:\(order.orderNumber)
|
||||
项目:\(order.projectName.isEmpty ? "--" : order.projectName)
|
||||
金额:¥\(order.orderAmount)
|
||||
"""
|
||||
}
|
||||
return "订单号:\(orderNumber)"
|
||||
}
|
||||
|
||||
private func displayVerificationStatus(for item: WriteOffOrderItem) -> String {
|
||||
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? "待核销" : text
|
||||
@ -536,6 +626,10 @@ struct OrdersView: View {
|
||||
canVerify(item) ? AppDesign.primary : Color(hex: 0x22C55E)
|
||||
}
|
||||
|
||||
private func cardBackgroundColor(for item: WriteOffOrderItem) -> Color {
|
||||
matchedScannedOrderNumber == item.orderNumber ? Color(hex: 0xEEF6FF) : .white
|
||||
}
|
||||
|
||||
private func statusColor(for status: Int) -> Color {
|
||||
switch status {
|
||||
case 18:
|
||||
|
||||
42
suixinkanTests/Fixtures/store_order_detail_success.json
Normal file
42
suixinkanTests/Fixtures/store_order_detail_success.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"order_number": "ORDV2001",
|
||||
"order_type": "19",
|
||||
"order_type_label": "多点旅拍",
|
||||
"created_at": "2026-05-23 04:39:00",
|
||||
"order_status": "20",
|
||||
"order_status_name": "已支付",
|
||||
"actual_pay_amount": "299.50",
|
||||
"actual_refund_amount": 0,
|
||||
"pay_type_name": "微信支付",
|
||||
"pay_time": "2026-05-23 04:40:00",
|
||||
"complete_time": "",
|
||||
"user_id": "7001",
|
||||
"phone": "13800000000",
|
||||
"project_id": "5101",
|
||||
"project_name": "亲子跟拍",
|
||||
"remark": "需要精修",
|
||||
"multi_travel": {
|
||||
"project_info": {
|
||||
"settle_spot_num": "3",
|
||||
"single_spot_material_num": 8,
|
||||
"single_spot_photo_num": "2",
|
||||
"single_spot_video_num": 1
|
||||
},
|
||||
"shooting_list": [
|
||||
{
|
||||
"scenic_spot_id": "901",
|
||||
"photog_uid": "42",
|
||||
"scenic_spot_name": "东门",
|
||||
"staff_name": "",
|
||||
"photog_name": "张三",
|
||||
"status": "2",
|
||||
"start_avg": "4.8",
|
||||
"start": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,8 @@ final class NavigationRouterTests: XCTestCase {
|
||||
func testPushedRoutesHideTabBarByDefault() {
|
||||
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
|
||||
}
|
||||
|
||||
/// 测试切换订单 Tab 时会保存订单内部子入口。
|
||||
@ -25,4 +27,30 @@ final class NavigationRouterTests: XCTestCase {
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
}
|
||||
|
||||
/// 测试订单模块真实路由可被压入导航栈。
|
||||
func testOrderRoutesCanBePushedIntoRouterPath() throws {
|
||||
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)))
|
||||
|
||||
XCTAssertEqual(router.path.count, 2)
|
||||
XCTAssertEqual(router.path[0], .orders(.storeDetail(order)))
|
||||
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
|
||||
}
|
||||
|
||||
/// 返回测试用订单管理列表项。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
120
suixinkanTests/OrderDetailViewModelTests.swift
Normal file
120
suixinkanTests/OrderDetailViewModelTests.swift
Normal file
@ -0,0 +1,120 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
38
suixinkanTests/OrderNumberParserTests.swift
Normal file
38
suixinkanTests/OrderNumberParserTests.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// OrderNumberParserTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 订单号解析器测试,覆盖扫码内容中的常见订单号格式。
|
||||
final class OrderNumberParserTests: XCTestCase {
|
||||
/// 测试从 URL query 中解析 order_number。
|
||||
func testParseOrderNumberFromURLQuery() {
|
||||
XCTAssertEqual(
|
||||
OrderNumberParser.parse("https://example.com/check?foo=1&order_number=ORD123456"),
|
||||
"ORD123456"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试从 key-value 文本中解析订单号。
|
||||
func testParseOrderNumberFromKeyValueText() {
|
||||
XCTAssertEqual(OrderNumberParser.parse("order_number=ORD123456"), "ORD123456")
|
||||
XCTAssertEqual(OrderNumberParser.parse("order-number:ORD654321"), "ORD654321")
|
||||
}
|
||||
|
||||
/// 测试从纯文本中解析订单号。
|
||||
func testParsePlainOrderNumberText() {
|
||||
XCTAssertEqual(OrderNumberParser.parse(" VERIFY001 "), "VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试无效内容不会返回订单号。
|
||||
func testParseInvalidTextReturnsNil() {
|
||||
XCTAssertNil(OrderNumberParser.parse(""))
|
||||
XCTAssertNil(OrderNumberParser.parse("abc def"))
|
||||
XCTAssertNil(OrderNumberParser.parse("12345"))
|
||||
}
|
||||
}
|
||||
@ -87,7 +87,7 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88)
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88, storeId: 66)
|
||||
XCTAssertFalse(viewModel.writeOffOrders.isEmpty)
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: nil)
|
||||
@ -97,6 +97,7 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
XCTAssertFalse(viewModel.writeOffHasMore)
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].storeId, 66)
|
||||
}
|
||||
|
||||
/// 测试核销订单最后一页不会继续请求。
|
||||
@ -121,11 +122,12 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001")
|
||||
try await viewModel.verify(api: api, scenicId: 88, storeId: 66, orderNumber: "VERIFY001")
|
||||
|
||||
XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"])
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].scenicId, 88)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].storeId, 66)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].page, 1)
|
||||
XCTAssertEqual(viewModel.writeOffOrders.count, 1)
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
@ -133,6 +135,32 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
XCTAssertNil(viewModel.currentVerifyingOrderNumber)
|
||||
}
|
||||
|
||||
/// 测试扫码命中当前核销列表时返回可高亮订单。
|
||||
func testScanResultMatchesCurrentWriteOffOrder() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88)
|
||||
|
||||
let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "https://example.com/scan?order_number=VERIFY001"))
|
||||
|
||||
XCTAssertEqual(result.orderNumber, "VERIFY001")
|
||||
XCTAssertEqual(result.matched?.orderNumber, "VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试扫码未命中当前列表时仍保留解析出的订单号。
|
||||
func testScanResultKeepsParsedOrderNumberWhenCurrentListDoesNotContainIt() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88)
|
||||
|
||||
let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "order-number=VERIFY999"))
|
||||
|
||||
XCTAssertEqual(result.orderNumber, "VERIFY999")
|
||||
XCTAssertNil(result.matched)
|
||||
}
|
||||
|
||||
/// 测试重复核销提交会被忽略。
|
||||
func testVerifyIgnoresDuplicateSubmitWhileAlreadyVerifying() async throws {
|
||||
let api = MockOrderService()
|
||||
@ -220,15 +248,18 @@ private final class MockOrderService: OrderServing {
|
||||
|
||||
struct WriteOffListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let storeId: Int?
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
var storeResponses: [ListPayload<OrderEntity>] = []
|
||||
var writeOffResponses: [ListPayload<WriteOffOrderItem>] = []
|
||||
var detailResponses: [StoreOrderDetailResponse] = []
|
||||
var verifyError: Error?
|
||||
private(set) var orderListCalls: [OrderListCall] = []
|
||||
private(set) var writeOffListCalls: [WriteOffListCall] = []
|
||||
private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = []
|
||||
private(set) var verifiedOrderNumbers: [String] = []
|
||||
|
||||
/// 返回测试订单管理列表。
|
||||
@ -260,8 +291,8 @@ private final class MockOrderService: OrderServing {
|
||||
}
|
||||
|
||||
/// 返回测试核销订单列表。
|
||||
func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, page: page, pageSize: pageSize))
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
|
||||
return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst()
|
||||
}
|
||||
|
||||
@ -272,6 +303,12 @@ private final class MockOrderService: OrderServing {
|
||||
throw verifyError
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回测试订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
detailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession 测试替身,记录请求并返回固定响应。
|
||||
|
||||
Reference in New Issue
Block a user