Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View 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
)
)
}
}

View 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 ""
}
/// IntDoubleBool
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
}
/// BoolInt
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
}
/// DoubleInt
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
}
}

View 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。
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View 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
}()
}