Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
258
suixinkan_ios/Features/Orders/API/OrdersAPI.swift
Normal file
258
suixinkan_ios/Features/Orders/API/OrdersAPI.swift
Normal file
@ -0,0 +1,258 @@
|
||||
//
|
||||
// OrdersAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 订单服务协议,抽象订单列表、核销列表和核销操作以便测试替换。
|
||||
protocol OrderServing {
|
||||
/// 获取订单管理列表。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity>
|
||||
|
||||
/// 获取核销订单列表。
|
||||
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
|
||||
|
||||
/// 获取押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem>
|
||||
|
||||
/// 核销押金订单。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws
|
||||
|
||||
/// 申请押金订单退款。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws
|
||||
|
||||
/// 获取订单某个拍摄点的底片、成片和评价信息。
|
||||
func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse
|
||||
|
||||
/// 申请普通订单退款。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws
|
||||
|
||||
/// 获取订单历史拍摄信息。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse
|
||||
|
||||
/// 获取多点旅拍订单已核销打卡点列表。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem]
|
||||
|
||||
/// 提交多点旅拍任务素材。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 订单 API,封装订单管理、核销订单和订单核销接口。
|
||||
final class OrdersAPI: OrderServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化订单 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取订单管理列表。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20,
|
||||
orderStatus: Int? = nil,
|
||||
userPhone: String? = nil,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
isRefined: Int? = nil,
|
||||
isScenicAdmin: Bool = false
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let orderStatus, orderStatus > 0 {
|
||||
query.append(URLQueryItem(name: "order_status", value: "\(orderStatus)"))
|
||||
}
|
||||
if let userPhone, !userPhone.isEmpty {
|
||||
query.append(URLQueryItem(name: "user_phone", value: userPhone))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime, !endTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
if let isRefined, isRefined > 0 {
|
||||
query.append(URLQueryItem(name: "is_refined", value: "\(isRefined)"))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: isScenicAdmin ? "/api/app/scenic-admin/order/list" : "/api/yf-handset-app/photog/order/listv2",
|
||||
queryItems: query
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取核销订单列表。
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 核销指定订单号。
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/order-verify",
|
||||
body: WriteOffRequest(orderNumber: orderNumber)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取门店订单详情。
|
||||
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)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<DepositOrderListItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 核销押金订单。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-writeoff",
|
||||
body: DepositOrderWriteOffRequest(orderNumber: orderNumber)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 申请押金订单退款。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-refund",
|
||||
body: DepositOrderRefundRequest(orderNumber: orderNumber, refundReason: refundReason)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取订单某个拍摄点的底片、成片和评价信息。
|
||||
func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/order/shooting-detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "store_id", value: "\(storeId)"),
|
||||
URLQueryItem(name: "order_number", value: orderNumber),
|
||||
URLQueryItem(name: "scenic_spot_id", value: "\(scenicSpotId)"),
|
||||
URLQueryItem(name: "photog_uid", value: "\(photogUid)")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 申请普通订单退款。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/refund",
|
||||
body: OrderRefundRequest(
|
||||
orderNumber: orderNumber,
|
||||
refundType: refundType.rawValue,
|
||||
refundAmount: refundAmount,
|
||||
refundReason: refundReason
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取订单历史拍摄信息。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/shoot-history",
|
||||
queryItems: [URLQueryItem(name: "order_number", value: orderNumber)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取多点旅拍订单已核销打卡点列表。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list",
|
||||
queryItems: [URLQueryItem(name: "order_number", value: orderNumber)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交多点旅拍任务素材。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/upload-material",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
835
suixinkan_ios/Features/Orders/Models/OrderModels.swift
Normal file
835
suixinkan_ios/Features/Orders/Models/OrderModels.swift
Normal file
@ -0,0 +1,835 @@
|
||||
//
|
||||
// OrderModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 订单 Tab 内的子入口,表示订单管理或核销订单。
|
||||
enum OrdersEntry: Hashable {
|
||||
case storeOrders
|
||||
case verificationOrders
|
||||
}
|
||||
|
||||
/// 订单状态筛选实体,表示订单管理列表顶部的状态过滤项。
|
||||
struct OrderStatusFilter: Identifiable, Hashable {
|
||||
let id: Int
|
||||
let title: String
|
||||
}
|
||||
|
||||
/// 订单筛选常量,集中维护旧工程同步过来的订单状态。
|
||||
enum OrderFilters {
|
||||
static let statusFilters: [OrderStatusFilter] = [
|
||||
.init(id: 0, title: "全部"),
|
||||
.init(id: 18, title: "已付定金"),
|
||||
.init(id: 30, title: "已完成"),
|
||||
.init(id: 50, title: "已退款"),
|
||||
.init(id: -1, title: "需要精修"),
|
||||
.init(id: -2, title: "无需精修")
|
||||
]
|
||||
}
|
||||
|
||||
/// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。
|
||||
struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { orderNumber }
|
||||
|
||||
let photogUid: Int
|
||||
let orderNumber: String
|
||||
let remark: String
|
||||
let scenicAreaId: Int
|
||||
let payTime: String
|
||||
let completeTime: String
|
||||
let orderStatus: Int
|
||||
let orderStatusName: String
|
||||
let storeId: Int?
|
||||
let userId: Int
|
||||
let projectId: Int
|
||||
let phone: String
|
||||
let orderAmount: String
|
||||
let actualPayAmount: String
|
||||
let actualRefundAmount: String
|
||||
let refundAmount: String
|
||||
let payTypeName: String
|
||||
let payType: Int
|
||||
let projectName: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let depositPayTime: String
|
||||
let createdAt: String
|
||||
let photoTravel: OrderPhotoTravel?
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case photogUid = "photog_uid"
|
||||
case orderNumber = "order_number"
|
||||
case remark
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case payTime = "pay_time"
|
||||
case completeTime = "complete_time"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
case storeId = "store_id"
|
||||
case userId = "user_id"
|
||||
case projectId = "project_id"
|
||||
case phone
|
||||
case orderAmount = "order_amount"
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case actualRefundAmount = "actual_refund_amount"
|
||||
case refundAmount = "refund_amount"
|
||||
case payTypeName = "pay_type_name"
|
||||
case payType = "pay_type"
|
||||
case projectName = "project_name"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case depositPayTime = "deposit_pay_time"
|
||||
case createdAt = "created_at"
|
||||
case photoTravel = "photo_travel"
|
||||
case isNeedEdit
|
||||
case isRefined = "is_refined"
|
||||
}
|
||||
|
||||
/// 宽松解码订单字段,兼容后端数字和字符串混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
scenicAreaId = try container.decodeLossyInt(forKey: .scenicAreaId) ?? 0
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
completeTime = try container.decodeLossyString(forKey: .completeTime)
|
||||
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
storeId = try container.decodeLossyInt(forKey: .storeId)
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
|
||||
actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount)
|
||||
refundAmount = try container.decodeLossyString(forKey: .refundAmount)
|
||||
payTypeName = try container.decodeLossyString(forKey: .payTypeName)
|
||||
payType = try container.decodeLossyInt(forKey: .payType) ?? 0
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
|
||||
depositPayTime = try container.decodeLossyString(forKey: .depositPayTime)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
|
||||
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
|
||||
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍订单附加信息实体,表示修图、视频和核销相关数量。
|
||||
struct OrderPhotoTravel: Decodable, Equatable, Hashable {
|
||||
let orderPhotoNum: Int
|
||||
let orderVideoNum: Int
|
||||
let checkInTime: String
|
||||
let retouchGiftPhotoNum: Int
|
||||
let retouchGiftVideoNum: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderPhotoNum = "order_photo_num"
|
||||
case orderVideoNum = "order_video_num"
|
||||
case checkInTime = "check_in_time"
|
||||
case retouchGiftPhotoNum = "retouch_gift_photo_num"
|
||||
case retouchGiftVideoNum = "retouch_gift_video_num"
|
||||
}
|
||||
|
||||
/// 宽松解码旅拍附加信息。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderPhotoNum = try container.decodeLossyInt(forKey: .orderPhotoNum) ?? 0
|
||||
orderVideoNum = try container.decodeLossyInt(forKey: .orderVideoNum) ?? 0
|
||||
checkInTime = try container.decodeLossyString(forKey: .checkInTime)
|
||||
retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 0
|
||||
retouchGiftVideoNum = try container.decodeLossyInt(forKey: .retouchGiftVideoNum) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单列表实体,表示可核销订单卡片的展示和操作状态。
|
||||
struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { orderNumber }
|
||||
|
||||
let orderNumber: String
|
||||
let orderVerificationStatus: String
|
||||
let payTime: String
|
||||
let projectName: String
|
||||
let userPhone: String
|
||||
let orderAmount: String
|
||||
let orderStatusName: String
|
||||
let orderVerificationTime: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderVerificationStatus = "order_verification_status"
|
||||
case payTime = "pay_time"
|
||||
case projectName = "project_name"
|
||||
case userPhone = "user_phone"
|
||||
case orderAmount = "order_amount"
|
||||
case orderStatusName = "order_status_name"
|
||||
case orderVerificationTime = "order_verification_time"
|
||||
}
|
||||
|
||||
/// 宽松解码核销订单字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
orderVerificationStatus = try container.decodeLossyString(forKey: .orderVerificationStatus)
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
orderVerificationTime = try container.decodeLossyString(forKey: .orderVerificationTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单请求实体,提交手动输入的订单号。
|
||||
struct WriteOffRequest: Encodable {
|
||||
let orderNumber: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单列表实体,表示首页押金订单卡片的基础信息和操作状态。
|
||||
struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let amount: String
|
||||
let status: Int
|
||||
let statusName: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderNumber = "order_number"
|
||||
case userPhone = "user_phone"
|
||||
case amount
|
||||
case status
|
||||
case statusName = "status_name"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 宽松解码押金订单字段,兼容后端数字和字符串混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
amount = try container.decodeLossyString(forKey: .amount)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单核销请求实体。
|
||||
struct DepositOrderWriteOffRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单退款请求实体。
|
||||
struct DepositOrderRefundRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let refundReason: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundReason = "refund_reason"
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通订单退款模式,表示全额退款或部分退款。
|
||||
enum OrderRefundMode: Int, CaseIterable, Identifiable, Hashable {
|
||||
case full = 1
|
||||
case partial = 3
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 退款模式展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .full:
|
||||
return "全额退款"
|
||||
case .partial:
|
||||
return "部分退款"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通订单退款请求实体。
|
||||
struct OrderRefundRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let refundType: Int
|
||||
let refundAmount: String
|
||||
let refundReason: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundType = "refund_type"
|
||||
case refundAmount = "refund_amount"
|
||||
case refundReason = "refund_reason"
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。
|
||||
enum OrdersRoute: Hashable {
|
||||
case storeDetail(OrderEntity)
|
||||
case writeOffDetail(WriteOffOrderItem)
|
||||
case depositDetail(orderNumber: String)
|
||||
case depositShootingInfo(orderNumber: String, scenicSpotId: Int, photogUid: Int)
|
||||
case historicalShooting(orderNumber: String)
|
||||
case multiTravelTaskUpload(orderNumber: String)
|
||||
case orderTrailer(orderNumber: String, title: String)
|
||||
}
|
||||
|
||||
/// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。
|
||||
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 StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable {
|
||||
let scenicSpotId: Int
|
||||
let scenicSpotName: String
|
||||
let orderType: Int
|
||||
let orderTypeName: String
|
||||
let orderComment: StoreOrderComment?
|
||||
let materialList: [OrderMediaFile]
|
||||
let completeList: [OrderMediaFile]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case orderType = "order_type"
|
||||
case orderTypeName = "order_type_name"
|
||||
case orderComment = "order_comment"
|
||||
case materialList = "material_list"
|
||||
case completeList = "complete_list"
|
||||
}
|
||||
|
||||
/// 宽松解码拍摄信息详情。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0
|
||||
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeName = try container.decodeLossyString(forKey: .orderTypeName)
|
||||
orderComment = try container.decodeIfPresent(StoreOrderComment.self, forKey: .orderComment)
|
||||
materialList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .materialList) ?? []
|
||||
completeList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .completeList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单评价实体,表示拍摄、修图、景区、服务和相机评分。
|
||||
struct StoreOrderComment: Decodable, Equatable, Hashable {
|
||||
let starShooting: Int
|
||||
let starRetouching: Int
|
||||
let starScenery: Int
|
||||
let starService: Int
|
||||
let starCamera: Int
|
||||
let content: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case starShooting = "star_shooting"
|
||||
case starRetouching = "star_retouching"
|
||||
case starScenery = "star_scenery"
|
||||
case starService = "star_service"
|
||||
case starCamera = "star_camera"
|
||||
case content
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 宽松解码评价字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
starShooting = try container.decodeLossyInt(forKey: .starShooting) ?? 0
|
||||
starRetouching = try container.decodeLossyInt(forKey: .starRetouching) ?? 0
|
||||
starScenery = try container.decodeLossyInt(forKey: .starScenery) ?? 0
|
||||
starService = try container.decodeLossyInt(forKey: .starService) ?? 0
|
||||
starCamera = try container.decodeLossyInt(forKey: .starCamera) ?? 0
|
||||
content = try container.decodeLossyString(forKey: .content)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单媒体文件实体,表示底片、成片或历史拍摄媒体。
|
||||
struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { "\(fileUrl)-\(fileName)-\(uploadTime)" }
|
||||
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
let fileType: Int
|
||||
let fileSize: Int64
|
||||
let coverUrl: String
|
||||
let uploadTime: String
|
||||
|
||||
var isVideo: Bool {
|
||||
fileType == 1
|
||||
}
|
||||
|
||||
var previewURLString: String {
|
||||
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
case fileType = "file_type"
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
case uploadTime = "upload_time"
|
||||
}
|
||||
|
||||
/// 宽松解码媒体文件字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
fileName = try container.decodeLossyString(forKey: .fileName)
|
||||
fileUrl = try container.decodeLossyString(forKey: .fileUrl)
|
||||
fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0
|
||||
fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0)
|
||||
coverUrl = try container.decodeLossyString(forKey: .coverUrl)
|
||||
uploadTime = try container.decodeLossyString(forKey: .uploadTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史拍摄响应实体,表示订单多点位历史拍摄媒体。
|
||||
struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable {
|
||||
let projectName: String
|
||||
let projectType: Int
|
||||
let projectTypeName: String
|
||||
let photogSpotList: [PhotogSpotItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case projectType = "project_type"
|
||||
case projectTypeName = "project_type_name"
|
||||
case photogSpotList = "photog_spot_list"
|
||||
}
|
||||
|
||||
/// 宽松解码历史拍摄响应。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
projectType = try container.decodeLossyInt(forKey: .projectType) ?? 0
|
||||
projectTypeName = try container.decodeLossyString(forKey: .projectTypeName)
|
||||
photogSpotList = try container.decodeIfPresent([PhotogSpotItem].self, forKey: .photogSpotList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史拍摄打卡点实体,表示某个摄影师在打卡点上传过的媒体。
|
||||
struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { "\(scenicSpotId)-\(photogUid)" }
|
||||
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
let scenicSpotName: String
|
||||
let photogNickname: String
|
||||
let photogName: String
|
||||
let files: [OrderMediaFile]
|
||||
|
||||
var photographerDisplayName: String {
|
||||
let nickname = photogNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return nickname.isEmpty ? photogName : nickname
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case photogNickname = "photog_nickname"
|
||||
case photogName = "photog_name"
|
||||
case files
|
||||
}
|
||||
|
||||
/// 宽松解码历史拍摄打卡点。
|
||||
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)
|
||||
photogNickname = try container.decodeLossyString(forKey: .photogNickname)
|
||||
photogName = try container.decodeLossyString(forKey: .photogName)
|
||||
files = try container.decodeIfPresent([OrderMediaFile].self, forKey: .files) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍已核销打卡点实体,用于任务上传时选择目标打卡点。
|
||||
struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
/// 宽松解码已核销打卡点字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍任务素材提交请求实体。
|
||||
struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let scenicSpotId: Int
|
||||
let cloudFile: [MultiTravelCloudFileItem]
|
||||
let uploadFile: [MultiTravelUploadFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case cloudFile = "cloud_file"
|
||||
case uploadFile = "upload_file"
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍云盘素材提交项。
|
||||
struct MultiTravelCloudFileItem: Encodable, Equatable {
|
||||
let fileId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileId = "file_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍本地上传素材提交项。
|
||||
struct MultiTravelUploadFileItem: Encodable, Equatable {
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单详情展示适配实体,统一列表摘要和详情接口响应的读取方式。
|
||||
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 {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double、Bool 或数字字符串宽松解码成整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Bool、Int 或字符串宽松解码成布尔值。
|
||||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value != 0
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
case "0", "false", "no":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
49
suixinkan_ios/Features/Orders/Orders.md
Normal file
49
suixinkan_ios/Features/Orders/Orders.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Orders 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传和尾片入口闭环。
|
||||
|
||||
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
||||
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
||||
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。
|
||||
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。
|
||||
- `OrderRefundViewModel`:管理普通订单退款入口判断、全额/部分退款金额校验和提交保护。
|
||||
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。
|
||||
- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。
|
||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
||||
- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。
|
||||
- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。
|
||||
|
||||
## 数据流程
|
||||
|
||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentRole` 读取角色 ID。景区管理员角色 `roleId == 53` 使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
||||
|
||||
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。
|
||||
|
||||
订单管理点击订单卡片进入订单详情。详情页优先用 `/api/app/store/order/detail` 按 `store_id + order_number` 拉取完整数据;缺少门店 ID 或接口失败时,保留列表卡片摘要作为兜底展示,并提示当前详情不完整。
|
||||
|
||||
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
||||
|
||||
押金订单入口由首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片。
|
||||
|
||||
普通订单退款只在 `orderStatus == 18 || orderStatus == 30`、`orderType != 19` 且可退金额大于 0 时展示入口。全额退款使用可退金额,部分退款要求金额大于 0、最多两位小数且不能超过可退金额。押金退款要求填写退款原因,普通退款和押金退款都不保存表单数据,提交成功后刷新对应订单数据。
|
||||
|
||||
历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。
|
||||
|
||||
任务上传仅对 `orderType == 19` 的多点旅拍订单展示。页面先按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list` 获取已核销打卡点,再支持选择云盘素材和本地图片/视频;本地素材先通过 `OSSUploadService.uploadTaskFile` 上传,成功后和云盘文件一起提交到 `/api/yf-handset-app/photog/order/multi-travel/upload-material`。
|
||||
|
||||
视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。
|
||||
|
||||
## 路由边界
|
||||
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。
|
||||
|
||||
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。
|
||||
@ -0,0 +1,360 @@
|
||||
//
|
||||
// DepositOrderViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 押金订单列表页,支持查询、分页、核销和退款。
|
||||
final class DepositOrderListViewController: UIViewController {
|
||||
|
||||
private let viewModel = DepositOrderListViewModel()
|
||||
private var orderNumberField = UITextField()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金订单"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
setupHeader()
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120))
|
||||
orderNumberField.placeholder = "请输入押金订单号"
|
||||
orderNumberField.borderStyle = .roundedRect
|
||||
orderNumberField.autocorrectionType = .no
|
||||
orderNumberField.autocapitalizationType = .none
|
||||
|
||||
let detailButton = makePrimaryButton(title: "查看订单详情")
|
||||
detailButton.addTarget(self, action: #selector(openDetail), for: .touchUpInside)
|
||||
|
||||
header.addSubview(orderNumberField)
|
||||
header.addSubview(detailButton)
|
||||
orderNumberField.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
detailButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(orderNumberField.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
tableView.tableHeaderView = header
|
||||
}
|
||||
|
||||
@objc private func openDetail() {
|
||||
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !text.isEmpty else { return }
|
||||
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
if showLoading {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: true)
|
||||
}
|
||||
} else {
|
||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: true)
|
||||
}
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
|
||||
private func writeOff(_ item: DepositOrderListItem) {
|
||||
let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认核销", style: .default) { [weak self] _ in
|
||||
Task {
|
||||
guard let self else { return }
|
||||
let success = await self.viewModel.writeOff(api: self.appServices.ordersAPI, scenicId: self.appServices.accountContext.currentScenic?.id, orderNumber: item.orderNumber)
|
||||
if success { self.showToast("核销成功") }
|
||||
else if let message = self.viewModel.errorMessage { self.showToast(message) }
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func refund(_ item: DepositOrderListItem) {
|
||||
let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "提交", style: .default) { [weak self] _ in
|
||||
Task {
|
||||
guard let self else { return }
|
||||
let reason = alert.textFields?.first?.text ?? ""
|
||||
let success = await self.viewModel.refund(api: self.appServices.ordersAPI, scenicId: self.appServices.accountContext.currentScenic?.id, orderNumber: item.orderNumber, reason: reason)
|
||||
if success { self.showToast("退款申请已提交") }
|
||||
else if let message = self.viewModel.errorMessage { self.showToast(message) }
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
max(viewModel.orders.count, 1)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
"押金订单列表 \(viewModel.orders.count)/\(viewModel.total)"
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.orders.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单"
|
||||
cell.selectionStyle = .none
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
|
||||
let item = viewModel.orders[indexPath.row]
|
||||
cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber)
|
||||
cell.onDetail = { [weak self] in
|
||||
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) }
|
||||
}
|
||||
cell.onWriteOff = { [weak self] in self?.writeOff(item) }
|
||||
cell.onRefund = { [weak self] in self?.refund(item) }
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return }
|
||||
Task {
|
||||
await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class DepositOrderCell: UITableViewCell {
|
||||
static let reuseID = "DepositOrderCell"
|
||||
var onDetail: (() -> Void)?
|
||||
var onWriteOff: (() -> Void)?
|
||||
var onRefund: (() -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
amountLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||||
amountLabel.textColor = AppDesignUIKit.primary
|
||||
|
||||
let detailButton = UIButton(type: .system)
|
||||
detailButton.setTitle("详情", for: .normal)
|
||||
detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside)
|
||||
let writeOffButton = UIButton(type: .system)
|
||||
writeOffButton.setTitle("核销", for: .normal)
|
||||
writeOffButton.addTarget(self, action: #selector(writeOffTapped), for: .touchUpInside)
|
||||
let refundButton = UIButton(type: .system)
|
||||
refundButton.setTitle("退款", for: .normal)
|
||||
refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside)
|
||||
|
||||
let buttonRow = UIStackView(arrangedSubviews: [detailButton, writeOffButton, refundButton])
|
||||
buttonRow.axis = .horizontal
|
||||
buttonRow.distribution = .fillEqually
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, amountLabel, buttonRow])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
contentView.addSubview(stack)
|
||||
contentView.addSubview(statusLabel)
|
||||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
|
||||
statusLabel.snp.makeConstraints { make in make.trailing.top.equalToSuperview().inset(12) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(item: DepositOrderListItem, isOperating: Bool) {
|
||||
titleLabel.text = item.orderNumber
|
||||
statusLabel.text = isOperating ? "处理中" : item.statusName
|
||||
amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)"
|
||||
}
|
||||
|
||||
@objc private func detailTapped() { onDetail?() }
|
||||
@objc private func writeOffTapped() { onWriteOff?() }
|
||||
@objc private func refundTapped() { onRefund?() }
|
||||
}
|
||||
|
||||
/// 押金订单详情页。
|
||||
final class DepositOrderDetailViewController: UIViewController {
|
||||
|
||||
private let orderNumber: String
|
||||
private let viewModel = DepositOrderDetailViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
init(orderNumber: String) {
|
||||
self.orderNumber = orderNumber
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金订单详情"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
api: appServices.ordersAPI,
|
||||
storeId: appServices.accountContext.currentStore?.id,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderDetailViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.detail == nil ? 1 : 8
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
guard let detail = viewModel.detail else {
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情")
|
||||
return cell
|
||||
}
|
||||
let rows: [(String, String)] = [
|
||||
("订单号", detail.orderNumber),
|
||||
("状态", detail.orderStatusName),
|
||||
("类型", detail.orderTypeLabel),
|
||||
("付款金额", detail.actualPayAmount),
|
||||
("手机号", detail.phone),
|
||||
("项目", detail.projectName),
|
||||
("创建时间", detail.createdAt),
|
||||
("备注", detail.remark)
|
||||
]
|
||||
cell.textLabel?.text = rows[indexPath.row].0
|
||||
cell.detailTextLabel?.text = rows[indexPath.row].1
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金拍摄信息页。
|
||||
final class DepositOrderShootingInfoViewController: UIViewController {
|
||||
|
||||
private let orderNumber: String
|
||||
private let scenicSpotId: Int
|
||||
private let photogUid: Int
|
||||
private let viewModel = DepositOrderShootingInfoViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
return table
|
||||
}()
|
||||
|
||||
init(orderNumber: String, scenicSpotId: Int, photogUid: Int) {
|
||||
self.orderNumber = orderNumber
|
||||
self.scenicSpotId = scenicSpotId
|
||||
self.photogUid = photogUid
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "押金拍摄信息"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(
|
||||
api: appServices.ordersAPI,
|
||||
storeId: appServices.accountContext.currentStore?.id,
|
||||
orderNumber: orderNumber,
|
||||
scenicSpotId: scenicSpotId,
|
||||
photogUid: photogUid
|
||||
)
|
||||
}
|
||||
tableView.reloadData()
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension DepositOrderShootingInfoViewController: UITableViewDataSource {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
viewModel.detail == nil ? 1 : 3
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
guard let detail = viewModel.detail else { return 1 }
|
||||
switch section {
|
||||
case 0: return 2
|
||||
case 1: return detail.materialList.count
|
||||
default: return detail.completeList.count
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
guard viewModel.detail != nil else { return nil }
|
||||
switch section {
|
||||
case 1: return "素材"
|
||||
case 2: return "成片"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let detail = viewModel.detail else {
|
||||
let cell = UITableViewCell()
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? "加载中..."
|
||||
return cell
|
||||
}
|
||||
if indexPath.section == 0 {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.row == 0 {
|
||||
cell.textLabel?.text = "打卡点"
|
||||
cell.detailTextLabel?.text = detail.scenicSpotName
|
||||
} else {
|
||||
cell.textLabel?.text = "拍摄评分"
|
||||
cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--"
|
||||
}
|
||||
return cell
|
||||
}
|
||||
let files = indexPath.section == 1 ? detail.materialList : detail.completeList
|
||||
let media = files[indexPath.row]
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
cell.textLabel?.text = media.fileName
|
||||
cell.detailTextLabel?.text = media.fileUrl
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,301 @@
|
||||
//
|
||||
// OrderCodeScannerViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单扫码错误实体,描述相机不可用、权限拒绝和元数据异常。
|
||||
enum OrderScannerError: LocalizedError {
|
||||
case cameraUnavailable
|
||||
case permissionDenied
|
||||
case invalidMetadata
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .cameraUnavailable:
|
||||
"当前设备不可用相机,无法扫码。"
|
||||
case .permissionDenied:
|
||||
"未开启相机权限,请在系统设置中允许后重试。"
|
||||
case .invalidMetadata:
|
||||
"未识别到有效条码,请重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单扫码页,包装 AVFoundation 扫码控制器并提供关闭与结果回调。
|
||||
final class OrderCodeScannerViewController: UIViewController {
|
||||
|
||||
var onScanResult: ((Result<String, Error>) -> Void)?
|
||||
|
||||
private let scannerController = OrderScannerCaptureViewController()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
title = "扫码核销"
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped))
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
showUnavailablePlaceholder()
|
||||
#else
|
||||
if AppUITestLaunchState.isRunningUITests || AVCaptureDevice.default(for: .video) == nil {
|
||||
showUnavailablePlaceholder()
|
||||
} else {
|
||||
embedScanner()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func embedScanner() {
|
||||
scannerController.onScanResult = { [weak self] result in
|
||||
self?.onScanResult?(result)
|
||||
}
|
||||
addChild(scannerController)
|
||||
view.addSubview(scannerController.view)
|
||||
scannerController.view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
scannerController.didMove(toParent: self)
|
||||
|
||||
let hintLabel = UILabel()
|
||||
hintLabel.text = "请将二维码/条码放入取景框"
|
||||
hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.footnote, weight: .medium)
|
||||
hintLabel.textColor = .white
|
||||
hintLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55)
|
||||
hintLabel.layer.cornerRadius = 16
|
||||
hintLabel.clipsToBounds = true
|
||||
hintLabel.textAlignment = .center
|
||||
view.addSubview(hintLabel)
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.xxLarge)
|
||||
make.height.equalTo(32)
|
||||
make.width.greaterThanOrEqualTo(220)
|
||||
}
|
||||
}
|
||||
|
||||
private func showUnavailablePlaceholder() {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppMetrics.Spacing.small
|
||||
stack.alignment = .center
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: "camera.viewfinder"))
|
||||
icon.tintColor = .white
|
||||
icon.contentMode = .scaleAspectFit
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "当前环境不可用相机"
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
|
||||
titleLabel.textColor = .white
|
||||
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.text = "请在真机上使用扫码核销"
|
||||
messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
messageLabel.textColor = UIColor.white.withAlphaComponent(0.72)
|
||||
|
||||
stack.addArrangedSubview(icon)
|
||||
stack.addArrangedSubview(titleLabel)
|
||||
stack.addArrangedSubview(messageLabel)
|
||||
view.addSubview(stack)
|
||||
icon.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(48)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// AVFoundation 扫码控制器,管理相机权限、预览层、识别类型和手电筒。
|
||||
final class OrderScannerCaptureViewController: 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
|
||||
return view
|
||||
}()
|
||||
|
||||
private lazy var torchButton: UIButton = {
|
||||
let button = Self.makeScannerButton(title: "手电筒")
|
||||
button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private lazy var retryButton: UIButton = {
|
||||
let button = Self.makeScannerButton(title: "重新扫描")
|
||||
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) -> UIButton {
|
||||
var config = UIButton.Configuration.filled()
|
||||
config.title = title
|
||||
config.baseForegroundColor = .white
|
||||
config.baseBackgroundColor = UIColor.black.withAlphaComponent(0.42)
|
||||
config.cornerStyle = .capsule
|
||||
return UIButton(configuration: config)
|
||||
}
|
||||
|
||||
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: .main)
|
||||
let desiredTypes: [AVMetadataObject.ObjectType] = [.qr, .ean8, .ean13, .pdf417, .code39, .code93, .code128, .dataMatrix, .aztec, .itf14, .upce]
|
||||
let supportedTypes = desiredTypes.filter { metadataOutput.availableMetadataObjectTypes.contains($0) }
|
||||
guard !supportedTypes.isEmpty else { throw OrderScannerError.invalidMetadata }
|
||||
metadataOutput.metadataObjectTypes = supportedTypes
|
||||
|
||||
let preview = AVCaptureVideoPreviewLayer(session: session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
preview.frame = view.layer.bounds
|
||||
view.layer.insertSublayer(preview, at: 0)
|
||||
previewLayer = preview
|
||||
torchButton.isEnabled = videoDevice.hasTorch
|
||||
}
|
||||
|
||||
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(torchButton)
|
||||
view.addSubview(retryButton)
|
||||
|
||||
scanFrameView.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-40)
|
||||
make.width.height.equalTo(240)
|
||||
}
|
||||
torchButton.snp.makeConstraints { make in
|
||||
make.leading.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18)
|
||||
}
|
||||
retryButton.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func toggleTorch() {
|
||||
updateTorch(enabled: !(videoDevice?.isTorchActive ?? false))
|
||||
}
|
||||
|
||||
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.configuration?.title = enabled ? "关闭手电" : "手电筒"
|
||||
} catch {
|
||||
torchButton.configuration?.title = "手电筒不可用"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func restartScan() {
|
||||
hasFinishedScan = false
|
||||
updateTorch(enabled: false)
|
||||
startSession()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
//
|
||||
// OrderDetailViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。
|
||||
final class StoreOrderDetailViewController: UIViewController {
|
||||
|
||||
private let item: OrderEntity
|
||||
private let viewModel: OrderDetailViewModel
|
||||
private let refundViewModel = OrderRefundViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
init(item: OrderEntity) {
|
||||
self.item = item
|
||||
self.viewModel = OrderDetailViewModel(item: item)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "订单详情"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
Task { await loadDetail() }
|
||||
}
|
||||
|
||||
private func loadDetail() async {
|
||||
await appServices.globalLoading.withLoading(message: "加载详情中...") {
|
||||
await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id)
|
||||
}
|
||||
if let error = viewModel.errorMessage {
|
||||
showToast(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyOrderNumber() {
|
||||
UIPasteboard.general.string = viewModel.display.orderNumber
|
||||
showToast("订单号已复制")
|
||||
}
|
||||
|
||||
private func presentRefund() {
|
||||
refundViewModel.begin(item: item)
|
||||
let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "退款原因" }
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "提交", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.refundViewModel.reason = alert.textFields?.first?.text ?? ""
|
||||
Task {
|
||||
let success = await self.refundViewModel.submit(api: self.appServices.ordersAPI, item: self.item)
|
||||
if success {
|
||||
self.showToast("退款申请已提交")
|
||||
await self.viewModel.load(api: self.appServices.ordersAPI, fallbackStoreId: self.appServices.accountContext.currentStore?.id, forceReload: true)
|
||||
} else if let message = self.refundViewModel.errorMessage {
|
||||
self.showToast(message)
|
||||
}
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 6 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch section {
|
||||
case 0: return viewModel.contextMessage == nil ? 0 : 1
|
||||
case 1: return 7
|
||||
case 2: return 4
|
||||
case 3: return 4
|
||||
case 4: return viewModel.shootingList.count
|
||||
case 5: return 5
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
switch section {
|
||||
case 1: "订单信息"
|
||||
case 2: "支付信息"
|
||||
case 3: "客户与项目"
|
||||
case 4 where !viewModel.shootingList.isEmpty: "拍摄点"
|
||||
case 5: "后续功能"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
let display = viewModel.display
|
||||
|
||||
switch indexPath.section {
|
||||
case 0:
|
||||
cell.textLabel?.text = viewModel.contextMessage
|
||||
cell.textLabel?.numberOfLines = 0
|
||||
case 1:
|
||||
let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = display.orderNumber
|
||||
case 1: cell.detailTextLabel?.text = display.orderStatusName
|
||||
case 2: cell.detailTextLabel?.text = display.orderTypeLabel
|
||||
case 3: cell.detailTextLabel?.text = display.createdAt
|
||||
case 4: cell.detailTextLabel?.text = displayPayTime
|
||||
case 5: cell.detailTextLabel?.text = display.completeTime
|
||||
default:
|
||||
cell.textLabel?.textColor = AppDesignUIKit.primary
|
||||
cell.selectionStyle = .default
|
||||
}
|
||||
case 2:
|
||||
let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))"
|
||||
case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))"
|
||||
case 2: cell.detailTextLabel?.text = display.payTypeName
|
||||
default: cell.detailTextLabel?.text = "\(display.userId)"
|
||||
}
|
||||
case 3:
|
||||
let rows = ["手机号", "关联项目", "项目 ID", "备注"]
|
||||
cell.textLabel?.text = rows[indexPath.row]
|
||||
switch indexPath.row {
|
||||
case 0: cell.detailTextLabel?.text = display.phone
|
||||
case 1: cell.detailTextLabel?.text = display.projectName
|
||||
case 2: cell.detailTextLabel?.text = "\(display.projectId)"
|
||||
default: cell.detailTextLabel?.text = display.remark
|
||||
}
|
||||
case 4:
|
||||
let shooting = viewModel.shootingList[indexPath.row]
|
||||
cell.textLabel?.text = shooting.scenicSpotName
|
||||
cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName
|
||||
case 5:
|
||||
let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"]
|
||||
cell.textLabel?.text = actions[indexPath.row]
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.selectionStyle = .default
|
||||
if indexPath.row == 4, !refundViewModel.canRefund(item) {
|
||||
cell.isUserInteractionEnabled = false
|
||||
cell.textLabel?.textColor = AppDesignUIKit.textSecondary
|
||||
}
|
||||
default: break
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return }
|
||||
guard indexPath.section == 5 else { return }
|
||||
let orderNumber = viewModel.display.orderNumber
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
|
||||
case 1 where item.orderType == 19:
|
||||
HomeMenuRouting.pushOrders(.multiTravelTaskUpload(orderNumber: orderNumber), from: self)
|
||||
case 2:
|
||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "视频预告"), from: self)
|
||||
case 3:
|
||||
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
|
||||
case 4:
|
||||
presentRefund()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
private var displayPayTime: String {
|
||||
let payTime = viewModel.display.payTime
|
||||
if payTime.isEmpty || payTime == "0" || payTime.hasPrefix("1970") { return item.depositPayTime }
|
||||
return payTime
|
||||
}
|
||||
|
||||
private func emptyToZero(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单详情页,展示核销列表项摘要信息。
|
||||
final class WriteOffOrderDetailViewController: UIViewController {
|
||||
|
||||
private let item: WriteOffOrderItem
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
return table
|
||||
}()
|
||||
|
||||
init(item: WriteOffOrderItem) {
|
||||
self.item = item
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "核销详情"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
}
|
||||
|
||||
extension WriteOffOrderDetailViewController: UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 }
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
cell.textLabel?.text = "订单号"
|
||||
cell.detailTextLabel?.text = item.orderNumber
|
||||
case 1:
|
||||
cell.textLabel?.text = "项目"
|
||||
cell.detailTextLabel?.text = item.projectName
|
||||
case 2:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = item.userPhone
|
||||
case 3:
|
||||
cell.textLabel?.text = "金额"
|
||||
cell.detailTextLabel?.text = "¥\(item.orderAmount)"
|
||||
case 4:
|
||||
cell.textLabel?.text = "状态"
|
||||
cell.detailTextLabel?.text = item.orderStatusName
|
||||
default:
|
||||
cell.textLabel?.text = "核销时间"
|
||||
cell.detailTextLabel?.text = item.orderVerificationTime
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,268 @@
|
||||
//
|
||||
// OrderTailUploadViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 多点旅拍任务上传页,负责选择打卡点和素材后提交到订单接口。
|
||||
final class MultiTravelTaskUploadViewController: UIViewController {
|
||||
|
||||
private let viewModel: MultiTravelTaskUploadViewModel
|
||||
|
||||
private let orderField = UITextField()
|
||||
private let spotButton = UIButton(type: .system)
|
||||
private let cloudListLabel = UILabel()
|
||||
private let localListLabel = UILabel()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
|
||||
init(initialOrderNumber: String) {
|
||||
self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "任务上传"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FA)
|
||||
setupForm()
|
||||
bindViewModel()
|
||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||
}
|
||||
|
||||
private func bindViewModel() {
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.applyViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupForm() {
|
||||
orderField.borderStyle = .roundedRect
|
||||
orderField.placeholder = "关联订单号"
|
||||
orderField.autocorrectionType = .no
|
||||
orderField.autocapitalizationType = .none
|
||||
orderField.text = viewModel.orderNumber
|
||||
orderField.addTarget(self, action: #selector(orderChanged), for: .editingChanged)
|
||||
|
||||
spotButton.setTitle("选择打卡点", for: .normal)
|
||||
spotButton.addTarget(self, action: #selector(selectSpot), for: .touchUpInside)
|
||||
|
||||
cloudListLabel.numberOfLines = 0
|
||||
cloudListLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
localListLabel.numberOfLines = 0
|
||||
localListLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
|
||||
submitButton.configuration = .filled()
|
||||
submitButton.configuration?.title = "保存任务素材"
|
||||
submitButton.configuration?.baseBackgroundColor = AppDesignUIKit.primary
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
let refreshButton = UIButton(type: .system)
|
||||
refreshButton.setTitle("刷新打卡点", for: .normal)
|
||||
refreshButton.addTarget(self, action: #selector(refreshSpots), for: .touchUpInside)
|
||||
|
||||
let pickLocalButton = UIButton(type: .system)
|
||||
pickLocalButton.setTitle("选择图片/视频", for: .normal)
|
||||
pickLocalButton.addTarget(self, action: #selector(pickLocalFiles), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
labeledRow("订单号", orderField),
|
||||
refreshButton,
|
||||
spotButton,
|
||||
labeledRow("云盘素材", cloudListLabel),
|
||||
pickLocalButton,
|
||||
labeledRow("本地素材", localListLabel),
|
||||
submitButton
|
||||
])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
view.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
private func labeledRow(_ title: String, _ content: UIView) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||||
let row = UIStackView(arrangedSubviews: [label, content])
|
||||
row.axis = .vertical
|
||||
row.spacing = 8
|
||||
return row
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
orderField.text = viewModel.orderNumber
|
||||
spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal)
|
||||
spotButton.isEnabled = !viewModel.isLoadingSpots
|
||||
|
||||
if viewModel.selectedCloudFiles.isEmpty {
|
||||
cloudListLabel.text = "未选择云盘文件"
|
||||
} else {
|
||||
cloudListLabel.text = viewModel.selectedCloudFiles.map(\.fileName).joined(separator: "\n")
|
||||
}
|
||||
|
||||
if viewModel.selectedLocalFiles.isEmpty {
|
||||
localListLabel.text = "未选择本地文件"
|
||||
} else {
|
||||
localListLabel.text = viewModel.selectedLocalFiles.map { file in
|
||||
"\(file.fileName) \(file.statusText)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
submitButton.isEnabled = viewModel.canSubmit
|
||||
submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材"
|
||||
}
|
||||
|
||||
@objc private func orderChanged() {
|
||||
viewModel.orderNumber = orderField.text ?? ""
|
||||
}
|
||||
|
||||
@objc private func refreshSpots() {
|
||||
Task { await viewModel.loadSpots(api: appServices.ordersAPI) }
|
||||
}
|
||||
|
||||
@objc private func selectSpot() {
|
||||
guard !viewModel.spots.isEmpty else { return }
|
||||
let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
|
||||
for spot in viewModel.spots {
|
||||
let title = spot.name.isEmpty ? "打卡点 \(spot.id)" : spot.name
|
||||
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectSpot(id: spot.id)
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func pickLocalFiles() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.selectionLimit = 9
|
||||
config.filter = .any(of: [.images, .videos])
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
Task {
|
||||
let success = await viewModel.submit(
|
||||
api: appServices.ordersAPI,
|
||||
uploadService: appServices.ossUploadService,
|
||||
scenicId: appServices.accountContext.currentScenic?.id
|
||||
)
|
||||
if success {
|
||||
showToast("提交成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} else if let message = viewModel.errorMessage {
|
||||
showToast(message)
|
||||
}
|
||||
applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
for result in results {
|
||||
let provider = result.itemProvider
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in
|
||||
guard let self, let url else { return }
|
||||
let data = (try? Data(contentsOf: url)) ?? Data()
|
||||
DispatchQueue.main.async {
|
||||
self.viewModel.addLocalFile(data: data, fileName: url.lastPathComponent)
|
||||
self.applyViewModel()
|
||||
}
|
||||
}
|
||||
} else if provider.canLoadObject(ofClass: UIImage.self) {
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.viewModel.addLocalFile(data: data, fileName: "upload_\(Int(Date().timeIntervalSince1970)).jpg")
|
||||
self.applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史拍摄信息页。
|
||||
final class HistoricalShootingInfoViewController: UIViewController {
|
||||
|
||||
private let orderNumber: String
|
||||
private let viewModel = HistoricalShootingInfoViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
init(orderNumber: String) {
|
||||
self.orderNumber = orderNumber
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "历史拍摄"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
viewModel.onChange = { [weak self] in self?.tableView.reloadData() }
|
||||
Task { await loadData() }
|
||||
}
|
||||
|
||||
private func loadData() async {
|
||||
await appServices.globalLoading.withLoading {
|
||||
await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber)
|
||||
}
|
||||
if let message = viewModel.errorMessage { showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 2 : max(viewModel.spots.count, 1)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 1 ? "拍摄点位" : nil
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
cell.selectionStyle = .none
|
||||
if indexPath.section == 0 {
|
||||
cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型"
|
||||
cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName
|
||||
return cell
|
||||
}
|
||||
guard !viewModel.spots.isEmpty else {
|
||||
cell.textLabel?.text = viewModel.errorMessage ?? "暂无历史拍摄"
|
||||
return cell
|
||||
}
|
||||
let spot = viewModel.spots[indexPath.row]
|
||||
cell.textLabel?.text = spot.scenicSpotName
|
||||
cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)"
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,604 @@
|
||||
//
|
||||
// OrdersViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。
|
||||
final class OrdersViewController: UIViewController {
|
||||
|
||||
private let viewModel = OrdersViewModel()
|
||||
private var manualOrderNumber = ""
|
||||
private var scanHintMessage: String?
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .grouped)
|
||||
table.backgroundColor = AppDesignUIKit.pageBackground
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID)
|
||||
table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID)
|
||||
table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID)
|
||||
table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID)
|
||||
table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "订单"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
tableView.refreshControl = refreshControl
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
}
|
||||
|
||||
appServices.appRouter.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.viewModel.selectedEntry != self.appServices.appRouter.selectedOrdersEntry {
|
||||
self.viewModel.selectedEntry = self.appServices.appRouter.selectedOrdersEntry
|
||||
Task { await self.reload(showLoading: true) }
|
||||
}
|
||||
Task { await self.consumePendingScanCodeIfNeeded() }
|
||||
}
|
||||
appServices.accountContext.onChange = { [weak self] in
|
||||
Task { await self?.reload(showLoading: true) }
|
||||
}
|
||||
|
||||
viewModel.selectedEntry = appServices.appRouter.selectedOrdersEntry
|
||||
Task {
|
||||
await reload(showLoading: true)
|
||||
await consumePendingScanCodeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reload(showLoading: false)
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id }
|
||||
private var currentStoreId: Int? { appServices.accountContext.currentStore?.id }
|
||||
private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id }
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") {
|
||||
try await self.viewModel.reload(
|
||||
api: self.appServices.ordersAPI,
|
||||
scenicId: self.currentScenicId,
|
||||
storeId: self.currentStoreId,
|
||||
roleId: self.currentRoleId,
|
||||
showLoading: false
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
do {
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
try await viewModel.loadMoreStoreOrders(
|
||||
api: appServices.ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
roleId: currentRoleId
|
||||
)
|
||||
} else {
|
||||
try await viewModel.loadMoreWriteOffOrders(
|
||||
api: appServices.ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
storeId: currentStoreId
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func switchEntry(_ entry: OrdersEntry) {
|
||||
guard viewModel.selectedEntry != entry else { return }
|
||||
viewModel.selectedEntry = entry
|
||||
appServices.appRouter.selectedOrdersEntry = entry
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "核销中...") {
|
||||
try await self.viewModel.verify(
|
||||
api: self.appServices.ordersAPI,
|
||||
scenicId: scenicId,
|
||||
storeId: self.currentStoreId,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
showToast("核销成功")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func consumePendingScanCodeIfNeeded() async {
|
||||
guard viewModel.selectedEntry == .verificationOrders,
|
||||
let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
|
||||
handleScanResult(code)
|
||||
}
|
||||
|
||||
private func handleScanResult(_ rawCode: String) {
|
||||
guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||
showToast("未识别到有效订单号")
|
||||
return
|
||||
}
|
||||
if parsed.matched != nil {
|
||||
confirmVerify(orderNumber: parsed.orderNumber)
|
||||
} else {
|
||||
scanHintMessage = "扫码成功,当前列表未找到该订单"
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmVerify(orderNumber: String) {
|
||||
let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认核销", style: .default) { [weak self] _ in
|
||||
Task { await self?.verify(orderNumber: orderNumber) }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentScanner() {
|
||||
let scanner = OrderCodeScannerViewController()
|
||||
scanner.onScanResult = { [weak self, weak scanner] result in
|
||||
scanner?.dismiss(animated: true)
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let code):
|
||||
self.handleScanResult(code)
|
||||
case .failure(let error):
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: scanner)
|
||||
nav.modalPresentationStyle = .fullScreen
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
private func presentDateFilter() {
|
||||
let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in
|
||||
self?.viewModel.filterStartDate = nil
|
||||
self?.viewModel.filterEndDate = nil
|
||||
Task { await self?.reload(showLoading: true) }
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "近7天", style: .default) { [weak self] _ in
|
||||
let end = Date()
|
||||
let start = Calendar.current.date(byAdding: .day, value: -6, to: end) ?? end
|
||||
self?.viewModel.filterStartDate = start
|
||||
self?.viewModel.filterEndDate = end
|
||||
Task { await self?.reload(showLoading: true) }
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func presentStatusFilter() {
|
||||
let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet)
|
||||
for filter in OrderFilters.statusFilters {
|
||||
sheet.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectedStatus = filter.id
|
||||
Task { await self?.reload(showLoading: true) }
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension OrdersViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
guard currentScenicId != nil else { return 1 }
|
||||
return viewModel.selectedEntry == .storeOrders ? 3 : 3
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
if section == 0 { return 1 }
|
||||
if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 }
|
||||
if currentScenicId == nil { return 1 }
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1)
|
||||
}
|
||||
return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
if indexPath.section == 0 {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell
|
||||
let services = appServices
|
||||
cell.configure(
|
||||
selectedEntry: viewModel.selectedEntry,
|
||||
scenicName: services.accountContext.currentScenic?.name ?? "--",
|
||||
storeTotal: viewModel.storeTotal,
|
||||
writeOffTotal: viewModel.writeOffTotal,
|
||||
onSelectEntry: { [weak self] entry in self?.switchEntry(entry) }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
if currentScenicId == nil {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.height.equalTo(360)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
if indexPath.section == 1 {
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell
|
||||
let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
|
||||
cell.configure(
|
||||
statusTitle: statusTitle,
|
||||
phone: viewModel.searchPhone,
|
||||
onStatus: { [weak self] in self?.presentStatusFilter() },
|
||||
onDate: { [weak self] in self?.presentDateFilter() },
|
||||
onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text },
|
||||
onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } }
|
||||
)
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell
|
||||
cell.configure(
|
||||
manualOrderNumber: manualOrderNumber,
|
||||
isVerifying: viewModel.isVerifying,
|
||||
hint: scanHintMessage,
|
||||
onScan: { [weak self] in self?.presentScanner() },
|
||||
onManualChange: { [weak self] text in self?.manualOrderNumber = text },
|
||||
onVerify: { [weak self] in
|
||||
guard let self, !self.manualOrderNumber.isEmpty else { return }
|
||||
self.confirmVerify(orderNumber: self.manualOrderNumber)
|
||||
}
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
if viewModel.storeOrders.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
|
||||
return cell
|
||||
}
|
||||
let item = viewModel.storeOrders[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell
|
||||
cell.configure(item: item)
|
||||
return cell
|
||||
}
|
||||
|
||||
if viewModel.writeOffOrders.isEmpty {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray")
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) }
|
||||
return cell
|
||||
}
|
||||
let item = viewModel.writeOffOrders[indexPath.row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell
|
||||
cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 2 else { return }
|
||||
if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count {
|
||||
HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self)
|
||||
} else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count {
|
||||
HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.section == 2 else { return }
|
||||
let isLast: Bool
|
||||
if viewModel.selectedEntry == .storeOrders {
|
||||
isLast = indexPath.row == viewModel.storeOrders.count - 1
|
||||
} else {
|
||||
isLast = indexPath.row == viewModel.writeOffOrders.count - 1
|
||||
}
|
||||
if isLast { Task { await loadMore() } }
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
if indexPath.section == 0 { return 120 }
|
||||
if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 }
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cells
|
||||
|
||||
private final class OrdersHeaderCell: UITableViewCell {
|
||||
static let reuseID = "OrdersHeaderCell"
|
||||
private var onSelectEntry: ((OrdersEntry) -> Void)?
|
||||
|
||||
private let storeButton = UIButton(type: .system)
|
||||
private let verifyButton = UIButton(type: .system)
|
||||
private let leftPill = UILabel()
|
||||
private let rightPill = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16))
|
||||
}
|
||||
|
||||
let segmentBackground = UIView()
|
||||
segmentBackground.backgroundColor = UIColor(hex: 0xF3F4F6)
|
||||
segmentBackground.layer.cornerRadius = 8
|
||||
card.addSubview(segmentBackground)
|
||||
storeButton.addTarget(self, action: #selector(storeTapped), for: .touchUpInside)
|
||||
verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside)
|
||||
segmentBackground.addSubview(storeButton)
|
||||
segmentBackground.addSubview(verifyButton)
|
||||
|
||||
[leftPill, rightPill].forEach {
|
||||
$0.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
$0.numberOfLines = 2
|
||||
card.addSubview($0)
|
||||
}
|
||||
|
||||
segmentBackground.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(12)
|
||||
make.height.equalTo(40)
|
||||
}
|
||||
storeButton.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(2)
|
||||
make.width.equalToSuperview().multipliedBy(0.5)
|
||||
}
|
||||
verifyButton.snp.makeConstraints { make in
|
||||
make.trailing.top.bottom.equalToSuperview().inset(2)
|
||||
make.width.equalToSuperview().multipliedBy(0.5)
|
||||
}
|
||||
leftPill.snp.makeConstraints { make in
|
||||
make.leading.bottom.equalToSuperview().inset(12)
|
||||
make.top.equalTo(segmentBackground.snp.bottom).offset(10)
|
||||
make.width.equalToSuperview().multipliedBy(0.45)
|
||||
}
|
||||
rightPill.snp.makeConstraints { make in
|
||||
make.trailing.bottom.equalToSuperview().inset(12)
|
||||
make.top.equalTo(leftPill)
|
||||
make.width.equalToSuperview().multipliedBy(0.45)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) {
|
||||
self.onSelectEntry = onSelectEntry
|
||||
updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders)
|
||||
updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders)
|
||||
if selectedEntry == .storeOrders {
|
||||
leftPill.text = "当前景区\n\(scenicName)"
|
||||
rightPill.text = "订单总数\n\(storeTotal)"
|
||||
} else {
|
||||
leftPill.text = "核销订单\n\(writeOffTotal)"
|
||||
rightPill.text = "当前景区\n\(scenicName)"
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSegment(_ button: UIButton, title: String, selected: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium)
|
||||
button.setTitleColor(selected ? AppDesignUIKit.primary : AppDesignUIKit.textSecondary, for: .normal)
|
||||
button.backgroundColor = selected ? .white : .clear
|
||||
button.layer.cornerRadius = 6
|
||||
}
|
||||
|
||||
@objc private func storeTapped() { onSelectEntry?(.storeOrders) }
|
||||
@objc private func verifyTapped() { onSelectEntry?(.verificationOrders) }
|
||||
}
|
||||
|
||||
private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate {
|
||||
static let reuseID = "OrdersFilterCell"
|
||||
private let phoneField = UITextField()
|
||||
private var onPhoneChange: ((String) -> Void)?
|
||||
private var onSearch: (() -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16))
|
||||
}
|
||||
phoneField.delegate = self
|
||||
phoneField.keyboardType = .phonePad
|
||||
phoneField.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
|
||||
phoneField.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
phoneField.layer.cornerRadius = 6
|
||||
phoneField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||||
phoneField.leftViewMode = .always
|
||||
card.addSubview(phoneField)
|
||||
phoneField.snp.makeConstraints { make in
|
||||
make.leading.bottom.equalToSuperview().inset(12)
|
||||
make.height.equalTo(40)
|
||||
make.trailing.equalToSuperview().inset(100)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) {
|
||||
phoneField.text = phone
|
||||
self.onPhoneChange = onPhoneChange
|
||||
self.onSearch = onSearch
|
||||
}
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
onPhoneChange?(textField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate {
|
||||
static let reuseID = "OrdersWriteOffActionCell"
|
||||
private let manualField = UITextField()
|
||||
private var onManualChange: ((String) -> Void)?
|
||||
private var onVerify: (() -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
|
||||
manualField.delegate = self
|
||||
manualField.placeholder = "手动输入订单号"
|
||||
manualField.autocorrectionType = .no
|
||||
manualField.autocapitalizationType = .none
|
||||
manualField.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
manualField.layer.cornerRadius = 8
|
||||
manualField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
|
||||
manualField.leftViewMode = .always
|
||||
card.addSubview(manualField)
|
||||
manualField.snp.makeConstraints { make in
|
||||
make.leading.bottom.equalToSuperview().inset(12)
|
||||
make.height.equalTo(42)
|
||||
make.trailing.equalToSuperview().inset(90)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) {
|
||||
manualField.text = manualOrderNumber
|
||||
self.onManualChange = onManualChange
|
||||
self.onVerify = onVerify
|
||||
}
|
||||
|
||||
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||
onManualChange?(textField.text ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private final class OrderEntityCell: UITableViewCell {
|
||||
static let reuseID = "OrderEntityCell"
|
||||
private let titleLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .default
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
|
||||
amountLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
|
||||
amountLabel.textColor = AppDesignUIKit.primary
|
||||
phoneLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
phoneLabel.textColor = AppDesignUIKit.textSecondary
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, phoneLabel, amountLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 4
|
||||
card.addSubview(stack)
|
||||
card.addSubview(statusLabel)
|
||||
stack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(12) }
|
||||
statusLabel.snp.makeConstraints { make in make.trailing.top.equalToSuperview().inset(12) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(item: OrderEntity) {
|
||||
titleLabel.text = item.orderNumber
|
||||
statusLabel.text = item.orderStatusName
|
||||
amountLabel.text = "¥\(item.actualPayAmount.isEmpty ? item.orderAmount : item.actualPayAmount)"
|
||||
phoneLabel.text = item.phone
|
||||
}
|
||||
}
|
||||
|
||||
private final class WriteOffOrderCell: UITableViewCell {
|
||||
static let reuseID = "WriteOffOrderCell"
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) }
|
||||
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold)
|
||||
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
|
||||
subtitleLabel.textColor = AppDesignUIKit.textSecondary
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 4
|
||||
card.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(item: WriteOffOrderItem, isVerifying: Bool) {
|
||||
titleLabel.text = item.orderNumber
|
||||
subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
//
|
||||
// OrderDetailViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。
|
||||
final class OrderDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var detail: StoreOrderDetailResponse? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var errorMessage: String? { didSet { onChange?() } }
|
||||
private(set) var contextMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 当前页面用于展示的订单信息,详情接口成功时优先使用详情数据。
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,608 @@
|
||||
//
|
||||
// OrderLongTailViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。
|
||||
final class DepositOrderListViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var orders: [DepositOrderListItem] = [] { didSet { onChange?() } }
|
||||
private(set) var total = 0 { didSet { onChange?() } }
|
||||
private(set) var page = 1 { didSet { onChange?() } }
|
||||
private(set) var hasMore = false { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var loadingMore = false { didSet { onChange?() } }
|
||||
private(set) var operatingOrderNumber: String? { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 加载押金订单列表,缺少景区时清空旧数据且不请求接口。
|
||||
func reload(api: OrderServing, scenicId: Int?, reset: Bool) async {
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
|
||||
if reset {
|
||||
page = 1
|
||||
total = 0
|
||||
orders = []
|
||||
loading = true
|
||||
} else {
|
||||
guard !loadingMore, hasMore else { return }
|
||||
loadingMore = true
|
||||
}
|
||||
|
||||
defer {
|
||||
loading = false
|
||||
loadingMore = false
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await api.depositOrderList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||||
total = result.total
|
||||
if page == 1 {
|
||||
orders = result.list
|
||||
} else {
|
||||
orders.append(contentsOf: result.list)
|
||||
}
|
||||
page += 1
|
||||
hasMore = orders.count < result.total && !result.list.isEmpty
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
if reset {
|
||||
orders = []
|
||||
total = 0
|
||||
page = 1
|
||||
hasMore = false
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销押金订单,成功后刷新第一页。
|
||||
func writeOff(api: OrderServing, scenicId: Int?, orderNumber: String) async -> Bool {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty, operatingOrderNumber == nil else { return false }
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "缺少景区上下文"
|
||||
return false
|
||||
}
|
||||
|
||||
operatingOrderNumber = normalized
|
||||
defer { operatingOrderNumber = nil }
|
||||
|
||||
do {
|
||||
try await api.depositOrderWriteOff(orderNumber: normalized)
|
||||
await reload(api: api, scenicId: scenicId, reset: true)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 申请押金订单退款,原因必填,成功后刷新第一页。
|
||||
func refund(api: OrderServing, scenicId: Int?, orderNumber: String, reason: String) async -> Bool {
|
||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedOrderNumber.isEmpty, operatingOrderNumber == nil else { return false }
|
||||
guard !normalizedReason.isEmpty else {
|
||||
errorMessage = "请填写退款原因"
|
||||
return false
|
||||
}
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "缺少景区上下文"
|
||||
return false
|
||||
}
|
||||
|
||||
operatingOrderNumber = normalizedOrderNumber
|
||||
defer { operatingOrderNumber = nil }
|
||||
|
||||
do {
|
||||
try await api.depositOrderRefund(orderNumber: normalizedOrderNumber, refundReason: normalizedReason)
|
||||
await reload(api: api, scenicId: scenicId, reset: true)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
orders = []
|
||||
total = 0
|
||||
page = 1
|
||||
hasMore = false
|
||||
loading = false
|
||||
loadingMore = false
|
||||
operatingOrderNumber = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 押金订单详情 ViewModel,负责按门店和订单号加载详情。
|
||||
final class DepositOrderDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var detail: StoreOrderDetailResponse? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载押金订单详情,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let storeId, storeId > 0 else {
|
||||
reset(message: "当前账号缺少门店信息")
|
||||
return
|
||||
}
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
detail = nil
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: normalized)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
detail = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。
|
||||
final class DepositOrderShootingInfoViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var detail: StoreOrderShootingDetailResponse? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let storeId, storeId > 0 else {
|
||||
reset(message: "当前账号缺少门店信息")
|
||||
return
|
||||
}
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
detail = nil
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.storeOrderShootingDetail(
|
||||
storeId: storeId,
|
||||
orderNumber: normalized,
|
||||
scenicSpotId: scenicSpotId,
|
||||
photogUid: photogUid
|
||||
)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
detail = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。
|
||||
final class OrderRefundViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var mode: OrderRefundMode = .full { didSet { onChange?() } }
|
||||
var amount = "" { didSet { onChange?() } }
|
||||
var reason = "" { didSet { onChange?() } }
|
||||
private(set) var submitting = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 判断订单是否允许展示普通退款入口。
|
||||
func canRefund(_ item: OrderEntity) -> Bool {
|
||||
(item.orderStatus == 18 || item.orderStatus == 30) &&
|
||||
item.orderType != 19 &&
|
||||
availableAmount(for: item) > 0
|
||||
}
|
||||
|
||||
/// 打开退款表单时初始化默认值。
|
||||
func begin(item: OrderEntity) {
|
||||
mode = .full
|
||||
amount = availableAmountText(for: item)
|
||||
reason = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 提交普通订单退款。
|
||||
func submit(api: OrderServing, item: OrderEntity) async -> Bool {
|
||||
guard !submitting else { return false }
|
||||
guard canRefund(item) else {
|
||||
errorMessage = "当前订单不可退款"
|
||||
return false
|
||||
}
|
||||
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedReason.isEmpty else {
|
||||
errorMessage = "请输入退款原因"
|
||||
return false
|
||||
}
|
||||
|
||||
let refundAmount: String
|
||||
switch mode {
|
||||
case .full:
|
||||
refundAmount = availableAmountText(for: item)
|
||||
case .partial:
|
||||
guard let normalized = Self.normalizedMoney(amount) else {
|
||||
errorMessage = "请输入有效的退款金额"
|
||||
return false
|
||||
}
|
||||
guard (Decimal(string: normalized) ?? 0) <= availableAmount(for: item) else {
|
||||
errorMessage = "退款金额不能大于可退金额"
|
||||
return false
|
||||
}
|
||||
refundAmount = normalized
|
||||
}
|
||||
|
||||
submitting = true
|
||||
errorMessage = nil
|
||||
defer { submitting = false }
|
||||
|
||||
do {
|
||||
try await api.orderRefund(
|
||||
orderNumber: item.orderNumber,
|
||||
refundType: mode,
|
||||
refundAmount: refundAmount,
|
||||
refundReason: normalizedReason
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算普通订单可退金额。
|
||||
func availableAmount(for item: OrderEntity) -> Decimal {
|
||||
let paid = Self.decimalValue(item.actualPayAmount)
|
||||
if paid > 0 { return paid }
|
||||
return Self.decimalValue(item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount)
|
||||
}
|
||||
|
||||
/// 返回可退金额展示和提交文案。
|
||||
func availableAmountText(for item: OrderEntity) -> String {
|
||||
Self.moneyText(availableAmount(for: item))
|
||||
}
|
||||
|
||||
/// 将输入金额规范成两位小数,空值、非法值和超过两位小数返回 nil。
|
||||
static func normalizedMoney(_ rawValue: String) -> String? {
|
||||
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")
|
||||
guard !text.isEmpty else { return nil }
|
||||
let pattern = #"^\d+(\.\d{1,2})?$"#
|
||||
guard text.range(of: pattern, options: .regularExpression) != nil,
|
||||
let value = Decimal(string: text),
|
||||
value > 0 else {
|
||||
return nil
|
||||
}
|
||||
return moneyText(value)
|
||||
}
|
||||
|
||||
private static func decimalValue(_ text: String) -> Decimal {
|
||||
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
|
||||
}
|
||||
|
||||
private static func moneyText(_ value: Decimal) -> String {
|
||||
let number = NSDecimalNumber(decimal: value)
|
||||
let handler = NSDecimalNumberHandler(
|
||||
roundingMode: .plain,
|
||||
scale: 2,
|
||||
raiseOnExactness: false,
|
||||
raiseOnOverflow: false,
|
||||
raiseOnUnderflow: false,
|
||||
raiseOnDivideByZero: false
|
||||
)
|
||||
return String(format: "%.2f", number.rounding(accordingToBehavior: handler).doubleValue)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。
|
||||
final class HistoricalShootingInfoViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var projectName = "" { didSet { onChange?() } }
|
||||
private(set) var projectTypeName = "" { didSet { onChange?() } }
|
||||
private(set) var spots: [PhotogSpotItem] = [] { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。
|
||||
func load(api: OrderServing, orderNumber: String) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
let result = try await api.multiTravelShootHistory(orderNumber: normalized)
|
||||
projectName = result.projectName
|
||||
projectTypeName = result.projectTypeName
|
||||
spots = result.photogSpotList
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
reset(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
projectName = ""
|
||||
projectTypeName = ""
|
||||
spots = []
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。
|
||||
final class MultiTravelTaskUploadViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var orderNumber = "" { didSet { onChange?() } }
|
||||
var selectedSpotId: Int? { didSet { onChange?() } }
|
||||
private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = [] { didSet { onChange?() } }
|
||||
var selectedCloudFiles: [TaskCloudSelectionItem] = [] { didSet { onChange?() } }
|
||||
var selectedLocalFiles: [TaskLocalUploadItem] = [] { didSet { onChange?() } }
|
||||
private(set) var isLoadingSpots = false { didSet { onChange?() } }
|
||||
private(set) var isSubmitting = false { didSet { onChange?() } }
|
||||
private(set) var didSubmitSuccessfully = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
private var loadedSpotOrderNumber = ""
|
||||
|
||||
/// 当前选中打卡点展示名。
|
||||
var selectedSpotName: String {
|
||||
spots.first(where: { $0.id == selectedSpotId })?.name ?? "请选择打卡点"
|
||||
}
|
||||
|
||||
/// 判断提交按钮是否满足基础启用条件。
|
||||
var canSubmit: Bool {
|
||||
!isSubmitting &&
|
||||
selectedSpotId != nil &&
|
||||
(!selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty) &&
|
||||
!selectedLocalFiles.contains(where: \.isUploading)
|
||||
}
|
||||
|
||||
/// 初始化任务上传表单订单号。
|
||||
init(initialOrderNumber: String = "") {
|
||||
orderNumber = initialOrderNumber
|
||||
}
|
||||
|
||||
/// 加载当前订单已核销打卡点,订单号为空时不请求接口。
|
||||
func loadSpots(api: OrderServing) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else {
|
||||
resetSpotSelection()
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingSpots = true
|
||||
defer { isLoadingSpots = false }
|
||||
|
||||
do {
|
||||
let result = try await api.multiTravelVerifiedScenicSpotList(orderNumber: normalized)
|
||||
spots = result
|
||||
loadedSpotOrderNumber = normalized
|
||||
if !spots.contains(where: { $0.id == selectedSpotId }) {
|
||||
selectedSpotId = spots.first?.id
|
||||
}
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
resetSpotSelection()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换选中的打卡点。
|
||||
func selectSpot(id: Int?) {
|
||||
selectedSpotId = id
|
||||
}
|
||||
|
||||
/// 合并云盘附件,按文件 ID 去重。
|
||||
func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) {
|
||||
var next = selectedCloudFiles
|
||||
for file in files where !next.contains(where: { $0.id == file.id }) {
|
||||
next.append(file)
|
||||
}
|
||||
selectedCloudFiles = next
|
||||
}
|
||||
|
||||
/// 移除指定云盘附件。
|
||||
func removeCloudFile(id: Int) {
|
||||
selectedCloudFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 添加本地图片或视频附件。
|
||||
func addLocalFile(data: Data, fileName: String) {
|
||||
selectedLocalFiles.append(
|
||||
TaskLocalUploadItem(
|
||||
id: UUID(),
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
fileType: Self.fileType(for: fileName),
|
||||
remark: "",
|
||||
uploadedURL: nil,
|
||||
progress: 0,
|
||||
errorMessage: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 移除指定本地附件。
|
||||
func removeLocalFile(id: UUID) {
|
||||
selectedLocalFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 提交多点旅拍任务素材;本地附件先上传 OSS,再提交最终 URL。
|
||||
@discardableResult
|
||||
func submit(api: OrderServing, uploadService: any OSSUploadServing, scenicId: Int?) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let requestContext = validateBeforeSubmit(scenicId: scenicId) else { return false }
|
||||
|
||||
isSubmitting = true
|
||||
didSubmitSuccessfully = false
|
||||
errorMessage = nil
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let uploadFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: requestContext.scenicId)
|
||||
try await api.multiTravelUploadMaterial(
|
||||
MultiTravelUploadMaterialRequest(
|
||||
orderNumber: requestContext.orderNumber,
|
||||
scenicSpotId: requestContext.scenicSpotId,
|
||||
cloudFile: selectedCloudFiles.map { MultiTravelCloudFileItem(fileId: $0.id) },
|
||||
uploadFile: uploadFiles
|
||||
)
|
||||
)
|
||||
didSubmitSuccessfully = true
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
|
||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "请先选择景区"
|
||||
return nil
|
||||
}
|
||||
guard !normalizedOrderNumber.isEmpty else {
|
||||
errorMessage = "请先填写订单号"
|
||||
return nil
|
||||
}
|
||||
guard loadedSpotOrderNumber == normalizedOrderNumber else {
|
||||
errorMessage = "请先刷新打卡点"
|
||||
return nil
|
||||
}
|
||||
guard let selectedSpotId else {
|
||||
errorMessage = "请选择打卡点"
|
||||
return nil
|
||||
}
|
||||
guard !selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty else {
|
||||
errorMessage = "请至少选择一个素材文件"
|
||||
return nil
|
||||
}
|
||||
guard !selectedLocalFiles.contains(where: \.isUploading) else {
|
||||
errorMessage = "文件上传中,请稍后提交"
|
||||
return nil
|
||||
}
|
||||
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
|
||||
}
|
||||
|
||||
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
|
||||
var uploadFiles: [MultiTravelUploadFileItem] = []
|
||||
for index in selectedLocalFiles.indices {
|
||||
if let uploadedURL = selectedLocalFiles[index].uploadedURL {
|
||||
uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: uploadedURL))
|
||||
continue
|
||||
}
|
||||
|
||||
selectedLocalFiles[index].errorMessage = nil
|
||||
let fileID = selectedLocalFiles[index].id
|
||||
do {
|
||||
let url = try await uploadService.uploadTaskFile(
|
||||
data: selectedLocalFiles[index].data,
|
||||
fileName: selectedLocalFiles[index].fileName,
|
||||
fileType: selectedLocalFiles[index].fileType,
|
||||
scenicId: scenicId,
|
||||
onProgress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.updateLocalFileProgress(id: fileID, progress: progress)
|
||||
}
|
||||
}
|
||||
)
|
||||
selectedLocalFiles[index].uploadedURL = url
|
||||
selectedLocalFiles[index].progress = 100
|
||||
uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: url))
|
||||
} catch {
|
||||
selectedLocalFiles[index].progress = 0
|
||||
selectedLocalFiles[index].errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return uploadFiles
|
||||
}
|
||||
|
||||
private func updateLocalFileProgress(id: UUID, progress: Int) {
|
||||
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
|
||||
selectedLocalFiles[index].progress = progress
|
||||
}
|
||||
|
||||
private func resetSpotSelection() {
|
||||
spots = []
|
||||
selectedSpotId = nil
|
||||
loadedSpotOrderNumber = ""
|
||||
isLoadingSpots = false
|
||||
}
|
||||
|
||||
private static func fileType(for fileName: String) -> Int {
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
|
||||
}
|
||||
|
||||
private struct SubmitContext {
|
||||
let scenicId: Int
|
||||
let orderNumber: String
|
||||
let scenicSpotId: Int
|
||||
}
|
||||
}
|
||||
213
suixinkan_ios/Features/Orders/ViewModels/OrdersViewModel.swift
Normal file
213
suixinkan_ios/Features/Orders/ViewModels/OrdersViewModel.swift
Normal file
@ -0,0 +1,213 @@
|
||||
//
|
||||
// OrdersViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 订单页面 ViewModel,管理订单列表、核销列表、筛选条件和手动核销流程。
|
||||
final class OrdersViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var selectedEntry: OrdersEntry = .storeOrders { didSet { onChange?() } }
|
||||
var selectedStatus = 0 { didSet { onChange?() } }
|
||||
var searchPhone = "" { didSet { onChange?() } }
|
||||
var filterStartDate: Date? { didSet { onChange?() } }
|
||||
var filterEndDate: Date? { didSet { onChange?() } }
|
||||
private(set) var loading = false { didSet { onChange?() } }
|
||||
private(set) var loadingMore = false { didSet { onChange?() } }
|
||||
private(set) var isVerifying = false { didSet { onChange?() } }
|
||||
private(set) var currentVerifyingOrderNumber: String? { didSet { onChange?() } }
|
||||
|
||||
private(set) var storeOrders: [OrderEntity] = [] { didSet { onChange?() } }
|
||||
private(set) var storeTotal = 0 { didSet { onChange?() } }
|
||||
private(set) var storePage = 1 { didSet { onChange?() } }
|
||||
private(set) var storeHasMore = false { didSet { onChange?() } }
|
||||
|
||||
private(set) var writeOffOrders: [WriteOffOrderItem] = [] { didSet { onChange?() } }
|
||||
private(set) var writeOffTotal = 0 { didSet { onChange?() } }
|
||||
private(set) var writeOffPage = 1 { didSet { onChange?() } }
|
||||
private(set) var writeOffHasMore = false { didSet { onChange?() } }
|
||||
|
||||
/// 返回去除首尾空白后的手机号搜索值。
|
||||
var normalizedPhone: String? {
|
||||
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// 根据当前子入口刷新对应列表。
|
||||
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, storeId: storeId, showLoading: showLoading)
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新订单管理列表,缺少景区时清空旧数据。
|
||||
func reloadStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
storeOrders = []
|
||||
storeTotal = 0
|
||||
storePage = 1
|
||||
storeHasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let result = try await api.orderList(
|
||||
scenicId: scenicId,
|
||||
page: 1,
|
||||
pageSize: pageSize,
|
||||
orderStatus: orderStatusQuery,
|
||||
userPhone: normalizedPhone,
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
)
|
||||
storeOrders = result.list
|
||||
storeTotal = result.total
|
||||
storePage = 1
|
||||
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 加载订单管理下一页。
|
||||
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
guard !loadingMore, storeHasMore, let scenicId else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
|
||||
let nextPage = storePage + 1
|
||||
let result = try await api.orderList(
|
||||
scenicId: scenicId,
|
||||
page: nextPage,
|
||||
pageSize: pageSize,
|
||||
orderStatus: orderStatusQuery,
|
||||
userPhone: normalizedPhone,
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
)
|
||||
storeOrders.append(contentsOf: result.list)
|
||||
storeTotal = result.total
|
||||
storePage = nextPage
|
||||
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 刷新核销订单列表,缺少景区时清空旧数据。
|
||||
func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
writeOffOrders = []
|
||||
writeOffTotal = 0
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
|
||||
writeOffOrders = result.list
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 加载核销订单下一页。
|
||||
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, storeId: storeId, page: nextPage, pageSize: pageSize)
|
||||
writeOffOrders.append(contentsOf: result.list)
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = nextPage
|
||||
writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 手动核销订单,并在成功后刷新核销列表。
|
||||
func verify(api: OrderServing, scenicId: Int, storeId: Int? = nil, orderNumber: String) async throws {
|
||||
guard !isVerifying else { return }
|
||||
|
||||
isVerifying = true
|
||||
currentVerifyingOrderNumber = orderNumber
|
||||
defer {
|
||||
isVerifying = false
|
||||
currentVerifyingOrderNumber = nil
|
||||
}
|
||||
|
||||
try await api.writeOff(orderNumber: orderNumber)
|
||||
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
|
||||
currentVerifyingOrderNumber = orderNumber
|
||||
}
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
private var orderStatusQuery: Int? {
|
||||
selectedStatus > 0 ? selectedStatus : nil
|
||||
}
|
||||
|
||||
private var startTimeQuery: String? {
|
||||
filterStartDate.map { Self.dayFormatter.string(from: $0) }
|
||||
}
|
||||
|
||||
private var endTimeQuery: String? {
|
||||
filterEndDate.map { Self.dayFormatter.string(from: $0) }
|
||||
}
|
||||
|
||||
private var refinedQuery: Int? {
|
||||
switch selectedStatus {
|
||||
case -1:
|
||||
return 1
|
||||
case -2:
|
||||
return 2
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user