实现订单 Tab 并接入扫码核销流程
This commit is contained in:
@ -14,6 +14,7 @@ final class NetworkServices {
|
|||||||
let authAPI: AuthAPI
|
let authAPI: AuthAPI
|
||||||
let profileAPI: ProfileAPI
|
let profileAPI: ProfileAPI
|
||||||
let statisticsAPI: StatisticsAPI
|
let statisticsAPI: StatisticsAPI
|
||||||
|
let orderAPI: OrderAPI
|
||||||
let uploadAPI: UploadAPI
|
let uploadAPI: UploadAPI
|
||||||
let ossUploadService: OSSUploadService
|
let ossUploadService: OSSUploadService
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ final class NetworkServices {
|
|||||||
authAPI = AuthAPI(client: client)
|
authAPI = AuthAPI(client: client)
|
||||||
profileAPI = ProfileAPI(client: client)
|
profileAPI = ProfileAPI(client: client)
|
||||||
statisticsAPI = StatisticsAPI(client: client)
|
statisticsAPI = StatisticsAPI(client: client)
|
||||||
|
orderAPI = OrderAPI(client: client)
|
||||||
uploadAPI = UploadAPI(client: client)
|
uploadAPI = UploadAPI(client: client)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
client.bindAuthTokenProvider {
|
client.bindAuthTokenProvider {
|
||||||
@ -37,6 +39,7 @@ final class NetworkServices {
|
|||||||
authAPI = AuthAPI(client: apiClient)
|
authAPI = AuthAPI(client: apiClient)
|
||||||
profileAPI = ProfileAPI(client: apiClient)
|
profileAPI = ProfileAPI(client: apiClient)
|
||||||
statisticsAPI = StatisticsAPI(client: apiClient)
|
statisticsAPI = StatisticsAPI(client: apiClient)
|
||||||
|
orderAPI = OrderAPI(client: apiClient)
|
||||||
uploadAPI = UploadAPI(client: apiClient)
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,62 @@ final class APIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 发送原始 JSON 字符串请求体(对齐 Android `TextContent` 裸 JSON POST)。
|
||||||
|
func sendRawJSON<Response: Decodable>(
|
||||||
|
method: HTTPMethod,
|
||||||
|
path: String,
|
||||||
|
jsonBody: String,
|
||||||
|
tokenOverride: String? = nil
|
||||||
|
) async throws -> Response {
|
||||||
|
let apiPath = path.hasPrefix("/") ? path : "/" + path
|
||||||
|
guard let url = URL(
|
||||||
|
string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiPath
|
||||||
|
) else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION")
|
||||||
|
request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE")
|
||||||
|
|
||||||
|
let token = tokenOverride ?? authTokenProvider?()
|
||||||
|
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
|
||||||
|
request.setValue(token, forHTTPHeaderField: "token")
|
||||||
|
}
|
||||||
|
|
||||||
|
request.httpBody = jsonBody.data(using: .utf8)
|
||||||
|
logRequest(request)
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: URLResponse
|
||||||
|
do {
|
||||||
|
(data, response) = try await session.data(for: request)
|
||||||
|
} catch is CancellationError {
|
||||||
|
logCancelled(for: request, reason: "CancellationError")
|
||||||
|
throw CancellationError()
|
||||||
|
} catch let error as URLError {
|
||||||
|
if error.code == .cancelled {
|
||||||
|
logCancelled(for: request, reason: "URLError.cancelled")
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
throw APIError.networkFailed(networkErrorMessage(for: error))
|
||||||
|
} catch {
|
||||||
|
throw APIError.networkFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
logResponse(for: request, response: response, data: data)
|
||||||
|
do {
|
||||||
|
try validateHTTPResponse(response, data: data)
|
||||||
|
return try decodeEnvelope(Response.self, from: data)
|
||||||
|
} catch let error as APIError {
|
||||||
|
notifySessionExpiredIfNeeded(for: error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。
|
/// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。
|
||||||
private func makeURLRequest<Response: Decodable>(
|
private func makeURLRequest<Response: Decodable>(
|
||||||
_ apiRequest: APIRequest<Response>,
|
_ apiRequest: APIRequest<Response>,
|
||||||
|
|||||||
243
suixinkan/Features/Orders/API/OrderAPI.swift
Normal file
243
suixinkan/Features/Orders/API/OrderAPI.swift
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
//
|
||||||
|
// OrderAPI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 订单模块 API,对齐 Android `NetworkApi` 订单区域。
|
||||||
|
final class OrderAPI {
|
||||||
|
private let client: APIClient
|
||||||
|
|
||||||
|
init(client: APIClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
func photographerOrderList(
|
||||||
|
scenicId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int = OrderListQueryBuilder.photographerPageSize,
|
||||||
|
orderStatus: Int = 0,
|
||||||
|
startTime: String? = nil,
|
||||||
|
endTime: String? = nil,
|
||||||
|
userPhone: String? = nil,
|
||||||
|
isRefined: Int = 0
|
||||||
|
) async throws -> OrderListResponse<OrderEntity> {
|
||||||
|
var items: [URLQueryItem] = [
|
||||||
|
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "page_size", value: String(pageSize)),
|
||||||
|
]
|
||||||
|
if let orderStatus = OrderListQueryBuilder.orderStatusQuery(tabIndex: orderStatus) {
|
||||||
|
items.append(URLQueryItem(name: "order_status", value: orderStatus))
|
||||||
|
}
|
||||||
|
if let startTime, !startTime.isEmpty { items.append(URLQueryItem(name: "start_time", value: startTime)) }
|
||||||
|
if let endTime, !endTime.isEmpty { items.append(URLQueryItem(name: "end_time", value: endTime)) }
|
||||||
|
if let userPhone, !userPhone.isEmpty { items.append(URLQueryItem(name: "user_phone", value: userPhone)) }
|
||||||
|
if let isRefined = OrderListQueryBuilder.isRefinedQuery(rTabIndex: isRefined) {
|
||||||
|
items.append(URLQueryItem(name: "is_refined", value: String(isRefined)))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/yf-handset-app/photog/order/listv2", queryItems: items)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenicAdminOrderList(
|
||||||
|
scenicId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int = OrderListQueryBuilder.photographerPageSize,
|
||||||
|
orderStatus: Int = 0,
|
||||||
|
startTime: String? = nil,
|
||||||
|
endTime: String? = nil,
|
||||||
|
userPhone: String? = nil,
|
||||||
|
isRefined: Int = 0
|
||||||
|
) async throws -> OrderListResponse<OrderEntity> {
|
||||||
|
var items: [URLQueryItem] = [
|
||||||
|
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "page_size", value: String(pageSize)),
|
||||||
|
]
|
||||||
|
if let orderStatus = OrderListQueryBuilder.orderStatusQuery(tabIndex: orderStatus) {
|
||||||
|
items.append(URLQueryItem(name: "order_status", value: orderStatus))
|
||||||
|
}
|
||||||
|
if let startTime, !startTime.isEmpty { items.append(URLQueryItem(name: "start_time", value: startTime)) }
|
||||||
|
if let endTime, !endTime.isEmpty { items.append(URLQueryItem(name: "end_time", value: endTime)) }
|
||||||
|
if let userPhone, !userPhone.isEmpty { items.append(URLQueryItem(name: "user_phone", value: userPhone)) }
|
||||||
|
if let isRefined = OrderListQueryBuilder.isRefinedQuery(rTabIndex: isRefined) {
|
||||||
|
items.append(URLQueryItem(name: "is_refined", value: String(isRefined)))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/app/scenic-admin/order/list", queryItems: items)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeOrderList(
|
||||||
|
storeId: Int?,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int = OrderListQueryBuilder.storePageSize,
|
||||||
|
userPhone: String? = nil,
|
||||||
|
orderStatus: Int? = nil,
|
||||||
|
orderType: Int? = nil,
|
||||||
|
startTime: String? = nil,
|
||||||
|
endTime: String? = nil
|
||||||
|
) async throws -> StoreOrderListData {
|
||||||
|
var items: [URLQueryItem] = [
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "page_size", value: String(pageSize)),
|
||||||
|
]
|
||||||
|
if let storeId { items.append(URLQueryItem(name: "store_id", value: String(storeId))) }
|
||||||
|
if let userPhone, !userPhone.isEmpty { items.append(URLQueryItem(name: "user_phone", value: userPhone)) }
|
||||||
|
if let orderStatus { items.append(URLQueryItem(name: "order_status", value: String(orderStatus))) }
|
||||||
|
if let orderType { items.append(URLQueryItem(name: "order_type", value: String(orderType))) }
|
||||||
|
if let startTime, !startTime.isEmpty { items.append(URLQueryItem(name: "start_time", value: startTime)) }
|
||||||
|
if let endTime, !endTime.isEmpty { items.append(URLQueryItem(name: "end_time", value: endTime)) }
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/app/store/order/list", queryItems: items)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderCheckIn(jsonBody: String) async throws -> OrderSignInResponse {
|
||||||
|
try await client.sendRawJSON(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/order/check-in",
|
||||||
|
jsonBody: jsonBody
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderVerify(jsonBody: String) async throws -> WriteOffOrderResponse {
|
||||||
|
try await client.sendRawJSON(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/order/order-verify",
|
||||||
|
jsonBody: jsonBody
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiTravelScenicSpotList(orderNumber: String, lat: Double, lng: Double) async throws -> MultiTravelScenicSpotListResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/yf-handset-app/photog/order/multi-travel/scenic-spot-list",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "order_number", value: orderNumber),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiTravelOrderVerify(_ request: MultiTravelOrderVerifyRequest) async throws -> OrderSignInResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/order/multi-travel/order-verify",
|
||||||
|
body: request
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelOrder(_ request: CancelRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/order/cancel", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refundOrder(_ request: OrderRefundRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/order/refund", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func giftRetouch(_ request: SendGiftRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/yf-handset-app/photog/order/gift-retouch", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRefined(orderNumber: String, isRefined: Int) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/order/refined",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "order_number", value: orderNumber),
|
||||||
|
URLQueryItem(name: "is_refined", value: String(isRefined)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cooperativeSalers(storeId: String?, page: String = "1", pageSize: String = "50") async throws -> CooperativeSalerListResponse {
|
||||||
|
var items = [
|
||||||
|
URLQueryItem(name: "page", value: page),
|
||||||
|
URLQueryItem(name: "page_size", value: pageSize),
|
||||||
|
]
|
||||||
|
if let storeId { items.append(URLQueryItem(name: "store_id", value: storeId)) }
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/yf-handset-app/photog/cooperative-salers", queryItems: items)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func referralOrders(saleUserId: String, page: String = "1", pageSize: String = "50") async throws -> ReferralOrderListResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/yf-handset-app/photog/sale-user/referral-orders",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "sale_user_id", value: saleUserId),
|
||||||
|
URLQueryItem(name: "page", value: page),
|
||||||
|
URLQueryItem(name: "page_size", value: pageSize),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindReferralOrder(orderNumber: String, referralOrderNo: String) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/sale-user/bind-referral-order",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "order_number", value: orderNumber),
|
||||||
|
URLQueryItem(name: "referral_order_no", value: referralOrderNo),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shootHistory(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 storeOrderVerify(_ request: StoreOrderVerifyRequest) async throws -> WriteOffOrderResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/store/order/order-verify", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeOrderRefund(_ request: StoreOrderRefundRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/store/order/refund", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailData {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/app/store/order/detail",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "store_id", value: String(storeId)),
|
||||||
|
URLQueryItem(name: "order_number", value: orderNumber),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
876
suixinkan/Features/Orders/Models/OrderModels.swift
Normal file
876
suixinkan/Features/Orders/Models/OrderModels.swift
Normal file
@ -0,0 +1,876 @@
|
|||||||
|
//
|
||||||
|
// OrderModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - List Response
|
||||||
|
|
||||||
|
/// 订单列表分页响应,字段为 `list`(非 `data`)。
|
||||||
|
struct OrderListResponse<T: Decodable>: Decodable {
|
||||||
|
let total: Int
|
||||||
|
let list: [T]
|
||||||
|
|
||||||
|
init(total: Int = 0, list: [T] = []) {
|
||||||
|
self.total = total
|
||||||
|
self.list = list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Photographer Order
|
||||||
|
|
||||||
|
struct OrderEntity: Decodable, Equatable {
|
||||||
|
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 userId: Int
|
||||||
|
let projectId: Int
|
||||||
|
let phone: String
|
||||||
|
let orderAmount: String
|
||||||
|
let actualPayAmount: String
|
||||||
|
let actualRefundAmount: String
|
||||||
|
let payTypeName: String
|
||||||
|
let payType: Int
|
||||||
|
let projectName: String
|
||||||
|
let orderType: Int
|
||||||
|
let orderTypeLabel: String
|
||||||
|
let depositPayTime: String
|
||||||
|
let createdAt: String
|
||||||
|
let photoTravel: PhotoTravelEntity?
|
||||||
|
let refundAmount: String
|
||||||
|
let multiTravel: MultiTravelEntity?
|
||||||
|
let isNeedEdit: Bool
|
||||||
|
let isRefined: Int
|
||||||
|
let canBindReferral: Int
|
||||||
|
let showOrderSource: Int
|
||||||
|
let referralOrderNo: String
|
||||||
|
let referralSalerName: String
|
||||||
|
let referralSalerPhotogRemarkName: String
|
||||||
|
let referralOrder: BoundReferralOrderEntity?
|
||||||
|
|
||||||
|
var displayReferralSalerName: String {
|
||||||
|
OrderDisplayName.saleUser(remark: referralSalerPhotogRemarkName, primary: referralSalerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 userId = "user_id"
|
||||||
|
case projectId = "project_id"
|
||||||
|
case phone
|
||||||
|
case orderAmount = "order_amount"
|
||||||
|
case actualPayAmount = "actual_pay_amount"
|
||||||
|
case actualRefundAmount = "actual_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 refundAmount = "refund_amount"
|
||||||
|
case multiTravel = "multi_travel"
|
||||||
|
case isNeedEdit
|
||||||
|
case isRefined = "is_refined"
|
||||||
|
case canBindReferral = "can_bind_referral"
|
||||||
|
case showOrderSource = "show_order_source"
|
||||||
|
case referralOrderNo = "referral_order_no"
|
||||||
|
case referralSalerName = "referral_saler_name"
|
||||||
|
case referralSalerPhotogRemarkName = "photog_remark_name"
|
||||||
|
case referralOrder = "referral_order"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
photogUid = try container.decodeIfPresent(Int.self, forKey: .photogUid) ?? 0
|
||||||
|
orderNumber = try container.decodeIfPresent(String.self, forKey: .orderNumber) ?? ""
|
||||||
|
remark = try container.decodeIfPresent(String.self, forKey: .remark)
|
||||||
|
scenicAreaId = try container.decodeIfPresent(Int.self, forKey: .scenicAreaId) ?? 0
|
||||||
|
payTime = try container.decodeIfPresent(String.self, forKey: .payTime) ?? ""
|
||||||
|
completeTime = try container.decodeIfPresent(String.self, forKey: .completeTime) ?? ""
|
||||||
|
orderStatus = try container.decodeIfPresent(Int.self, forKey: .orderStatus) ?? 0
|
||||||
|
orderStatusName = try container.decodeIfPresent(String.self, forKey: .orderStatusName) ?? ""
|
||||||
|
userId = try container.decodeIfPresent(Int.self, forKey: .userId) ?? 0
|
||||||
|
projectId = try container.decodeIfPresent(Int.self, forKey: .projectId) ?? 0
|
||||||
|
phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? ""
|
||||||
|
orderAmount = try Self.decodeFlexibleString(container: container, key: .orderAmount)
|
||||||
|
actualPayAmount = try Self.decodeFlexibleString(container: container, key: .actualPayAmount)
|
||||||
|
actualRefundAmount = try Self.decodeFlexibleString(container: container, key: .actualRefundAmount, defaultValue: "0")
|
||||||
|
payTypeName = try container.decodeIfPresent(String.self, forKey: .payTypeName) ?? ""
|
||||||
|
payType = try container.decodeIfPresent(Int.self, forKey: .payType) ?? 0
|
||||||
|
projectName = try container.decodeIfPresent(String.self, forKey: .projectName) ?? ""
|
||||||
|
orderType = try container.decodeIfPresent(Int.self, forKey: .orderType) ?? 0
|
||||||
|
orderTypeLabel = try container.decodeIfPresent(String.self, forKey: .orderTypeLabel) ?? ""
|
||||||
|
depositPayTime = try container.decodeIfPresent(String.self, forKey: .depositPayTime) ?? ""
|
||||||
|
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
|
||||||
|
photoTravel = try container.decodeIfPresent(PhotoTravelEntity.self, forKey: .photoTravel)
|
||||||
|
refundAmount = try Self.decodeFlexibleString(container: container, key: .refundAmount, defaultValue: "0")
|
||||||
|
multiTravel = try container.decodeIfPresent(MultiTravelEntity.self, forKey: .multiTravel)
|
||||||
|
isNeedEdit = try container.decodeIfPresent(Bool.self, forKey: .isNeedEdit) ?? true
|
||||||
|
isRefined = try container.decodeIfPresent(Int.self, forKey: .isRefined) ?? 0
|
||||||
|
canBindReferral = try container.decodeIfPresent(Int.self, forKey: .canBindReferral) ?? 0
|
||||||
|
showOrderSource = try container.decodeIfPresent(Int.self, forKey: .showOrderSource) ?? 0
|
||||||
|
referralOrderNo = try container.decodeIfPresent(String.self, forKey: .referralOrderNo) ?? ""
|
||||||
|
referralSalerName = try container.decodeIfPresent(String.self, forKey: .referralSalerName) ?? ""
|
||||||
|
referralSalerPhotogRemarkName = try container.decodeIfPresent(String.self, forKey: .referralSalerPhotogRemarkName) ?? ""
|
||||||
|
referralOrder = try container.decodeIfPresent(BoundReferralOrderEntity.self, forKey: .referralOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeFlexibleString(
|
||||||
|
container: KeyedDecodingContainer<CodingKeys>,
|
||||||
|
key: CodingKeys,
|
||||||
|
defaultValue: String = ""
|
||||||
|
) throws -> String {
|
||||||
|
if let string = try? container.decode(String.self, forKey: key) {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
if let intValue = try? container.decode(Int.self, forKey: key) {
|
||||||
|
return String(intValue)
|
||||||
|
}
|
||||||
|
if let doubleValue = try? container.decode(Double.self, forKey: key) {
|
||||||
|
return String(doubleValue)
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BoundReferralOrderEntity: Decodable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let orderNo: String
|
||||||
|
let saleUserId: Int
|
||||||
|
let salerName: String
|
||||||
|
let photogRemarkName: String
|
||||||
|
let salerPhone: String
|
||||||
|
let displayText: String
|
||||||
|
let userMobile: String
|
||||||
|
let remark: String
|
||||||
|
let bindOrderNumber: String
|
||||||
|
let sourceType: Int
|
||||||
|
let sourceTypeLabel: String
|
||||||
|
let status: Int
|
||||||
|
let statusLabel: String
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
var displaySalerName: String {
|
||||||
|
OrderDisplayName.saleUser(remark: photogRemarkName, primary: salerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case orderNo = "order_no"
|
||||||
|
case saleUserId = "sale_user_id"
|
||||||
|
case salerName = "saler_name"
|
||||||
|
case photogRemarkName = "photog_remark_name"
|
||||||
|
case salerPhone = "saler_phone"
|
||||||
|
case displayText = "display_text"
|
||||||
|
case userMobile = "user_mobile"
|
||||||
|
case remark
|
||||||
|
case bindOrderNumber = "bind_order_number"
|
||||||
|
case sourceType = "source_type"
|
||||||
|
case sourceTypeLabel = "source_type_label"
|
||||||
|
case status
|
||||||
|
case statusLabel = "status_label"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PhotoTravelEntity: Decodable, Equatable {
|
||||||
|
let orderPhotoNum: Int
|
||||||
|
let orderVideoNum: Int
|
||||||
|
let canCancelOrder: Bool
|
||||||
|
let needCheckIn: Bool
|
||||||
|
let checkInTime: String
|
||||||
|
let canGiftRetouch: Bool
|
||||||
|
let retouchGiftTime: String
|
||||||
|
let retouchGiftPhotoNum: Int
|
||||||
|
let retouchGiftVideoNum: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderPhotoNum = "order_photo_num"
|
||||||
|
case orderVideoNum = "order_video_num"
|
||||||
|
case canCancelOrder = "can_cancel_order"
|
||||||
|
case needCheckIn = "need_check_in"
|
||||||
|
case checkInTime = "check_in_time"
|
||||||
|
case canGiftRetouch = "can_gift_retouch"
|
||||||
|
case retouchGiftTime = "retouch_gift_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.decodeIfPresent(Int.self, forKey: .orderPhotoNum) ?? 0
|
||||||
|
orderVideoNum = try container.decodeIfPresent(Int.self, forKey: .orderVideoNum) ?? 0
|
||||||
|
canCancelOrder = try container.decodeIfPresent(Bool.self, forKey: .canCancelOrder) ?? false
|
||||||
|
needCheckIn = try container.decodeIfPresent(Bool.self, forKey: .needCheckIn) ?? false
|
||||||
|
checkInTime = try container.decodeIfPresent(String.self, forKey: .checkInTime) ?? ""
|
||||||
|
canGiftRetouch = try container.decodeIfPresent(Bool.self, forKey: .canGiftRetouch) ?? false
|
||||||
|
retouchGiftTime = try container.decodeIfPresent(String.self, forKey: .retouchGiftTime) ?? ""
|
||||||
|
retouchGiftPhotoNum = try container.decodeIfPresent(Int.self, forKey: .retouchGiftPhotoNum) ?? 0
|
||||||
|
retouchGiftVideoNum = try container.decodeIfPresent(Int.self, forKey: .retouchGiftVideoNum) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MultiTravelEntity: Decodable, Equatable {
|
||||||
|
let materialList: [MultiTravelMaterialEntity]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case materialList = "material_list"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(materialList: [MultiTravelMaterialEntity] = []) {
|
||||||
|
self.materialList = materialList
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
materialList = try container.decodeIfPresent([MultiTravelMaterialEntity].self, forKey: .materialList) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MultiTravelMaterialEntity: Decodable, Equatable {
|
||||||
|
let fileName: String
|
||||||
|
let fileUrl: String
|
||||||
|
let fileType: Int
|
||||||
|
let fileSize: Int64
|
||||||
|
let coverUrl: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case fileName = "file_name"
|
||||||
|
case fileUrl = "file_url"
|
||||||
|
case fileType = "file_type"
|
||||||
|
case fileSize = "file_size"
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Store Order
|
||||||
|
|
||||||
|
struct StoreOrderListData: Decodable, Equatable {
|
||||||
|
let total: Int
|
||||||
|
let list: [StoreOrderItem]
|
||||||
|
|
||||||
|
init(total: Int = 0, list: [StoreOrderItem] = []) {
|
||||||
|
self.total = total
|
||||||
|
self.list = list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreOrderItem: Decodable, Equatable {
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
orderNumber = try container.decodeIfPresent(String.self, forKey: .orderNumber) ?? ""
|
||||||
|
orderType = try container.decodeIfPresent(Int.self, forKey: .orderType) ?? 0
|
||||||
|
orderTypeLabel = try container.decodeIfPresent(String.self, forKey: .orderTypeLabel) ?? ""
|
||||||
|
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
|
||||||
|
orderStatus = try container.decodeIfPresent(Int.self, forKey: .orderStatus) ?? 0
|
||||||
|
orderStatusName = try container.decodeIfPresent(String.self, forKey: .orderStatusName) ?? ""
|
||||||
|
actualPayAmount = try container.decodeIfPresent(String.self, forKey: .actualPayAmount) ?? ""
|
||||||
|
actualRefundAmount = try container.decodeIfPresent(String.self, forKey: .actualRefundAmount) ?? ""
|
||||||
|
payTypeName = try container.decodeIfPresent(String.self, forKey: .payTypeName) ?? ""
|
||||||
|
payTime = try container.decodeIfPresent(String.self, forKey: .payTime) ?? ""
|
||||||
|
completeTime = try container.decodeIfPresent(String.self, forKey: .completeTime) ?? ""
|
||||||
|
userId = try container.decodeIfPresent(Int.self, forKey: .userId) ?? 0
|
||||||
|
phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? ""
|
||||||
|
projectId = try container.decodeIfPresent(Int.self, forKey: .projectId) ?? 0
|
||||||
|
projectName = try container.decodeIfPresent(String.self, forKey: .projectName) ?? ""
|
||||||
|
remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreOrderType {
|
||||||
|
static let offlineProduct = 4
|
||||||
|
static let multiTravelShoot = 19
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Request Bodies
|
||||||
|
|
||||||
|
struct CancelRequest: Encodable {
|
||||||
|
let orderNumber: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OrderRefundRequest: Encodable {
|
||||||
|
var orderNumber: String
|
||||||
|
var refundType: Int
|
||||||
|
var refundAmount: String
|
||||||
|
var refundReason: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case refundType = "refund_type"
|
||||||
|
case refundAmount = "refund_amount"
|
||||||
|
case refundReason = "refund_reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SendGiftRequest: Encodable {
|
||||||
|
let orderNumber: String
|
||||||
|
let photoNum: Int
|
||||||
|
let videoNum: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case photoNum = "photo_num"
|
||||||
|
case videoNum = "video_num "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreOrderRefundRequest: Encodable {
|
||||||
|
let storeId: Int
|
||||||
|
let orderNumber: String
|
||||||
|
let refundType: Int
|
||||||
|
let refundAmount: String
|
||||||
|
let refundReason: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case storeId = "store_id"
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case refundType = "refund_type"
|
||||||
|
case refundAmount = "refund_amount"
|
||||||
|
case refundReason = "refund_reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreOrderVerifyRequest: Encodable {
|
||||||
|
let method: String
|
||||||
|
let userId: Int
|
||||||
|
let orderNumber: String
|
||||||
|
let time: String
|
||||||
|
let storeId: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case method
|
||||||
|
case userId = "user_id"
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case time
|
||||||
|
case storeId = "store_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(userId: Int, orderNumber: String, time: String, storeId: Int) {
|
||||||
|
method = "order_verify"
|
||||||
|
self.userId = userId
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.time = time
|
||||||
|
self.storeId = storeId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MultiTravelOrderVerifyRequest: Encodable {
|
||||||
|
let method: String
|
||||||
|
let orderNumber: String
|
||||||
|
let userId: Int
|
||||||
|
let time: Int64
|
||||||
|
let checkSum: String
|
||||||
|
let scenicSpotId: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case method
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case userId = "user_id"
|
||||||
|
case time
|
||||||
|
case checkSum = "check_sum"
|
||||||
|
case scenicSpotId = "scenic_spot_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(orderNumber: String, userId: Int, time: Int64, checkSum: String, scenicSpotId: Int) {
|
||||||
|
method = "multi_verify"
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.userId = userId
|
||||||
|
self.time = time
|
||||||
|
self.checkSum = checkSum
|
||||||
|
self.scenicSpotId = scenicSpotId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Responses
|
||||||
|
|
||||||
|
struct OrderSignInResponse: Decodable, Equatable {
|
||||||
|
let checkIn: Bool
|
||||||
|
let orderNumber: String
|
||||||
|
let projectName: String
|
||||||
|
let photogName: String
|
||||||
|
let scenicName: String
|
||||||
|
let orderAmount: String
|
||||||
|
let depositAmount: String
|
||||||
|
let depositPayTime: String
|
||||||
|
let userPhone: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case checkIn = "check_in"
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case projectName = "project_name"
|
||||||
|
case photogName = "photog_name"
|
||||||
|
case scenicName = "scenic_name"
|
||||||
|
case orderAmount = "order_amount"
|
||||||
|
case depositAmount = "deposit_amount"
|
||||||
|
case depositPayTime = "deposit_pay_time"
|
||||||
|
case userPhone = "user_phone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WriteOffOrderResponse: Decodable, Equatable {
|
||||||
|
let orderVerify: Bool
|
||||||
|
let orderNumber: String
|
||||||
|
let projectName: String
|
||||||
|
let scenicName: String
|
||||||
|
let orderAmount: String
|
||||||
|
let payTime: String
|
||||||
|
let userPhone: String
|
||||||
|
let verifyTime: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case orderVerify = "order_verify"
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case projectName = "project_name"
|
||||||
|
case scenicName = "scenic_name"
|
||||||
|
case orderAmount = "order_amount"
|
||||||
|
case payTime = "pay_time"
|
||||||
|
case userPhone = "user_phone"
|
||||||
|
case verifyTime = "time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MultiTravelScenicSpotListResponse: Decodable {
|
||||||
|
let scenicSpot: [MultiTravelScenicSpotItem]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case scenicSpot = "scenic_spot"
|
||||||
|
}
|
||||||
|
|
||||||
|
var entityList: [MultiTravelScenicSpotEntity] {
|
||||||
|
scenicSpot?.map { $0.toEntity() } ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MultiTravelScenicSpotItem: Decodable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let cover: String
|
||||||
|
let scenicName: String
|
||||||
|
let scenicId: Int
|
||||||
|
let distance: Double
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, cover
|
||||||
|
case scenicName = "scenic_name"
|
||||||
|
case scenicId = "scenic_id"
|
||||||
|
case distance
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEntity() -> MultiTravelScenicSpotEntity {
|
||||||
|
MultiTravelScenicSpotEntity(
|
||||||
|
scenicSpotId: id,
|
||||||
|
name: name,
|
||||||
|
coverUrl: cover,
|
||||||
|
scenicName: scenicName,
|
||||||
|
distanceText: Self.formatDistance(distance),
|
||||||
|
address: scenicName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatDistance(_ distance: Double) -> String {
|
||||||
|
if distance <= 0 { return "当前点位" }
|
||||||
|
if distance < 1000 { return "\(Int(distance))米" }
|
||||||
|
let km = distance / 1000
|
||||||
|
let tenths = Int(km * 10)
|
||||||
|
return "约\(tenths / 10).\(tenths % 10)公里"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MultiTravelScenicSpotEntity: Equatable {
|
||||||
|
let scenicSpotId: Int
|
||||||
|
let name: String
|
||||||
|
let coverUrl: String
|
||||||
|
let scenicName: String
|
||||||
|
let distanceText: String
|
||||||
|
let address: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MultiTravelShootHistoryResponse: Decodable, Equatable {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PhotogSpotItem: Decodable, Equatable {
|
||||||
|
let scenicSpotId: Int
|
||||||
|
let photogUid: Int
|
||||||
|
let scenicSpotName: String
|
||||||
|
let photogNickname: String
|
||||||
|
let photogName: String
|
||||||
|
let files: [ShootHistoryFileItem]
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var photographerDisplayName: String {
|
||||||
|
photogNickname.isEmpty ? photogName : photogNickname
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageUrls: [String] {
|
||||||
|
files.compactMap { file in
|
||||||
|
if !file.coverUrl.isEmpty { return file.coverUrl }
|
||||||
|
if file.fileType == 2, !file.fileUrl.isEmpty { return file.fileUrl }
|
||||||
|
return file.fileUrl.isEmpty ? nil : file.fileUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ShootHistoryFileItem: Decodable, Equatable {
|
||||||
|
let fileName: String
|
||||||
|
let fileUrl: String
|
||||||
|
let fileType: Int
|
||||||
|
let fileSize: Int64
|
||||||
|
let coverUrl: String
|
||||||
|
let uploadTime: String
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreOrderDetailData: Decodable, Equatable {
|
||||||
|
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: StoreMultiTravelInfo?
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreMultiTravelInfo: Decodable, Equatable {
|
||||||
|
let projectInfo: StoreProjectInfo?
|
||||||
|
let shootingList: [StoreShootingListItem]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case projectInfo = "project_info"
|
||||||
|
case shootingList = "shooting_list"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreProjectInfo: Decodable, Equatable {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreShootingListItem: Decodable, Equatable {
|
||||||
|
let scenicSpotId: Int
|
||||||
|
let photogUid: Int
|
||||||
|
let scenicSpotName: String
|
||||||
|
let staffName: String
|
||||||
|
let photogName: String
|
||||||
|
let status: Int
|
||||||
|
let startAvg: Float?
|
||||||
|
let start: Float?
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayStaffName: String {
|
||||||
|
staffName.isEmpty ? photogName : staffName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Order Source
|
||||||
|
|
||||||
|
struct CooperativeSalerListResponse: Decodable {
|
||||||
|
let total: Int
|
||||||
|
let list: [CooperativeSalerEntity]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CooperativeSalerEntity: Decodable, Equatable {
|
||||||
|
let saleUserId: Int
|
||||||
|
let bindingId: Int
|
||||||
|
let name: String
|
||||||
|
let salerName: String
|
||||||
|
let photogRemarkName: String
|
||||||
|
let phone: String
|
||||||
|
let salerPhone: String
|
||||||
|
|
||||||
|
var displayId: Int { saleUserId }
|
||||||
|
var displayName: String { OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) }
|
||||||
|
var displayPhone: String { salerPhone.isEmpty ? phone : salerPhone }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case saleUserId = "sale_user_id"
|
||||||
|
case bindingId = "binding_id"
|
||||||
|
case name
|
||||||
|
case salerName = "saler_name"
|
||||||
|
case photogRemarkName = "photog_remark_name"
|
||||||
|
case phone
|
||||||
|
case salerPhone = "saler_phone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReferralOrderListResponse: Decodable {
|
||||||
|
let total: Int
|
||||||
|
let list: [ReferralOrderEntity]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReferralOrderEntity: Decodable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let referralOrderNo: String
|
||||||
|
let orderNo: String
|
||||||
|
let userMobile: String
|
||||||
|
let remark: String
|
||||||
|
let createdAt: String
|
||||||
|
let status: Int
|
||||||
|
let statusLabel: String
|
||||||
|
|
||||||
|
var displayReferralOrderNo: String { referralOrderNo.isEmpty ? orderNo : referralOrderNo }
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case referralOrderNo = "referral_order_no"
|
||||||
|
case orderNo = "order_no"
|
||||||
|
case userMobile = "user_mobile"
|
||||||
|
case remark
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case status
|
||||||
|
case statusLabel = "status_label"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Filters
|
||||||
|
|
||||||
|
enum PhotographerOrderFilter: CaseIterable {
|
||||||
|
case all
|
||||||
|
case paidDeposit
|
||||||
|
case completed
|
||||||
|
case refunded
|
||||||
|
case needsRefinement
|
||||||
|
case noRefinement
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .all: "全部"
|
||||||
|
case .paidDeposit: "已付定金"
|
||||||
|
case .completed: "已完成"
|
||||||
|
case .refunded: "已退款"
|
||||||
|
case .needsRefinement: "需要精修"
|
||||||
|
case .noRefinement: "无需精修"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabIndex: Int {
|
||||||
|
switch self {
|
||||||
|
case .all, .needsRefinement, .noRefinement: 0
|
||||||
|
case .paidDeposit: 18
|
||||||
|
case .completed: 30
|
||||||
|
case .refunded: 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rTabIndex: Int {
|
||||||
|
switch self {
|
||||||
|
case .needsRefinement: 1
|
||||||
|
case .noRefinement: 2
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(tabIndex: Int, rTabIndex: Int) -> PhotographerOrderFilter {
|
||||||
|
switch (tabIndex, rTabIndex) {
|
||||||
|
case (18, 0): .paidDeposit
|
||||||
|
case (30, 0): .completed
|
||||||
|
case (50, 0): .refunded
|
||||||
|
case (0, 1): .needsRefinement
|
||||||
|
case (0, 2): .noRefinement
|
||||||
|
default: .all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StoreOrderFilter: CaseIterable {
|
||||||
|
case all
|
||||||
|
case paid
|
||||||
|
case completed
|
||||||
|
case refunded
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .all: "全部"
|
||||||
|
case .paid: "已支付"
|
||||||
|
case .completed: "已完成"
|
||||||
|
case .refunded: "已退款"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabIndex: Int {
|
||||||
|
switch self {
|
||||||
|
case .all: 0
|
||||||
|
case .paid: 18
|
||||||
|
case .completed: 30
|
||||||
|
case .refunded: 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI tab 18 映射 API order_status=20。
|
||||||
|
var apiOrderStatus: Int {
|
||||||
|
switch self {
|
||||||
|
case .paid: 20
|
||||||
|
default: tabIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(tabIndex: Int) -> StoreOrderFilter {
|
||||||
|
switch tabIndex {
|
||||||
|
case 18: .paid
|
||||||
|
case 30: .completed
|
||||||
|
case 50: .refunded
|
||||||
|
default: .all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderTypeCode {
|
||||||
|
static let photographerFollow = 11
|
||||||
|
static let photographerFollowOfflineScan = 14
|
||||||
|
static let multiTravel = 19
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderDisplayName {
|
||||||
|
static func saleUser(remark: String, primary: String, fallback: String = "") -> String {
|
||||||
|
if !remark.isEmpty { return remark }
|
||||||
|
if !primary.isEmpty { return primary }
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderListQueryBuilder {
|
||||||
|
static let photographerPageSize = 10
|
||||||
|
static let storePageSize = 20
|
||||||
|
|
||||||
|
static func orderStatusQuery(tabIndex: Int) -> String? {
|
||||||
|
tabIndex == 0 ? nil : String(tabIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isRefinedQuery(rTabIndex: Int) -> Int? {
|
||||||
|
rTabIndex == 0 ? nil : rTabIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
static func storeOrderStatusQuery(tabIndex: Int) -> Int? {
|
||||||
|
let status = StoreOrderFilter.from(tabIndex: tabIndex).apiOrderStatus
|
||||||
|
return status == 0 ? nil : status
|
||||||
|
}
|
||||||
|
}
|
||||||
73
suixinkan/Features/Orders/Models/QRCodeScanResult.swift
Normal file
73
suixinkan/Features/Orders/Models/QRCodeScanResult.swift
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// QRCodeScanResult.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 扫码 JSON 解析结果,对齐 Android QR 分流。
|
||||||
|
struct QRCodeScanResult: Equatable {
|
||||||
|
let rawJSON: String
|
||||||
|
let method: String
|
||||||
|
let orderNumber: String
|
||||||
|
let userId: Int
|
||||||
|
let time: String
|
||||||
|
let timeLong: Int64
|
||||||
|
let checkSum: String
|
||||||
|
|
||||||
|
static func parse(_ content: String) -> QRCodeScanResult? {
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil }
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||||
|
|
||||||
|
let method = (json["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let orderNumber = (json["order_number"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let userId = Self.parseInt(json["user_id"])
|
||||||
|
let timeLong = Self.parseTimeLong(json["time"])
|
||||||
|
let time = Self.parseTimeString(json["time"])
|
||||||
|
let checkSum = (json["check_sum"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
return QRCodeScanResult(
|
||||||
|
rawJSON: trimmed,
|
||||||
|
method: method,
|
||||||
|
orderNumber: orderNumber,
|
||||||
|
userId: userId,
|
||||||
|
time: time,
|
||||||
|
timeLong: timeLong,
|
||||||
|
checkSum: checkSum
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseInt(_ value: Any?) -> Int {
|
||||||
|
if let intValue = value as? Int { return intValue }
|
||||||
|
if let stringValue = value as? String, let intValue = Int(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
if let doubleValue = value as? Double { return Int(doubleValue) }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseTimeLong(_ value: Any?) -> Int64 {
|
||||||
|
if let intValue = value as? Int64 { return intValue }
|
||||||
|
if let intValue = value as? Int { return Int64(intValue) }
|
||||||
|
if let doubleValue = value as? Double { return Int64(doubleValue) }
|
||||||
|
if let stringValue = value as? String, let intValue = Int64(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseTimeString(_ value: Any?) -> String {
|
||||||
|
if let stringValue = value as? String {
|
||||||
|
return stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
let longValue = parseTimeLong(value)
|
||||||
|
guard longValue > 0 else { return "" }
|
||||||
|
let seconds = longValue > 1_000_000_000_000 ? TimeInterval(longValue) / 1000 : TimeInterval(longValue)
|
||||||
|
let date = Date(timeIntervalSince1970: seconds)
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
203
suixinkan/Features/Orders/Utils/MainTabScanHandler.swift
Normal file
203
suixinkan/Features/Orders/Utils/MainTabScanHandler.swift
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
//
|
||||||
|
// MainTabScanHandler.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 主 Tab 中间扫码按钮结果处理,对齐 Android `MainTabViewModel.signIn`。
|
||||||
|
@MainActor
|
||||||
|
final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
|
||||||
|
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onShowSignIn: ((OrderSignInResponse) -> Void)?
|
||||||
|
var onShowWriteOff: ((WriteOffOrderResponse) -> Void)?
|
||||||
|
var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)?
|
||||||
|
var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)?
|
||||||
|
|
||||||
|
private let api: OrderAPI
|
||||||
|
private let locationManager = CLLocationManager()
|
||||||
|
private var locationContinuation: ((Result<CLLocationCoordinate2D, Error>) -> Void)?
|
||||||
|
private(set) var pendingMultiVerify: MultiVerifyParseData?
|
||||||
|
|
||||||
|
init(api: OrderAPI) {
|
||||||
|
self.api = api
|
||||||
|
super.init()
|
||||||
|
locationManager.delegate = self
|
||||||
|
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScanResult(_ scanResult: String) async {
|
||||||
|
guard !scanResult.isEmpty else {
|
||||||
|
onShowMessage?("请扫描正确的二维码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let parsed = QRCodeScanResult.parse(scanResult) else {
|
||||||
|
onShowMessage?("请扫描正确的二维码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parsed.method {
|
||||||
|
case "check_in":
|
||||||
|
await performCheckIn(scanResult: scanResult)
|
||||||
|
case "order_verify":
|
||||||
|
if AppStore.shared.currentAppRole == .storeAdmin {
|
||||||
|
await performStoreVerify(parsed: parsed)
|
||||||
|
} else {
|
||||||
|
await performPhotographerVerify(jsonBody: parsed.rawJSON)
|
||||||
|
}
|
||||||
|
case "multi_verify":
|
||||||
|
guard !parsed.orderNumber.isEmpty, parsed.userId > 0, parsed.timeLong > 0, !parsed.checkSum.isEmpty else {
|
||||||
|
onShowMessage?("请扫描正确的多点位核销码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let pending = MultiVerifyParseData(
|
||||||
|
orderNumber: parsed.orderNumber,
|
||||||
|
userId: parsed.userId,
|
||||||
|
time: parsed.timeLong,
|
||||||
|
checkSum: parsed.checkSum
|
||||||
|
)
|
||||||
|
pendingMultiVerify = pending
|
||||||
|
onMultiVerifyPending?(pending)
|
||||||
|
await fetchSpotsForMultiVerify(pending: pending)
|
||||||
|
case SaleUserQrParser.methodSaleUserBind:
|
||||||
|
if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 {
|
||||||
|
onShowMessage?("获客员绑定功能待接入")
|
||||||
|
} else {
|
||||||
|
onShowMessage?("请扫描正确的获客员二维码")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
onShowMessage?("未知二维码,请检查后重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmMultiVerify(pending: MultiVerifyParseData, spot: MultiTravelScenicSpotEntity) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.multiTravelOrderVerify(
|
||||||
|
MultiTravelOrderVerifyRequest(
|
||||||
|
orderNumber: pending.orderNumber,
|
||||||
|
userId: pending.userId,
|
||||||
|
time: pending.time,
|
||||||
|
checkSum: pending.checkSum,
|
||||||
|
scenicSpotId: spot.scenicSpotId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onShowSignIn?(response)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performCheckIn(scanResult: String) async {
|
||||||
|
guard var json = parseJSONObject(scanResult) else {
|
||||||
|
onShowMessage?("请扫描正确的二维码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json["scenic_id"] = String(AppStore.shared.currentScenicId)
|
||||||
|
guard let body = serializeJSON(json) else { return }
|
||||||
|
do {
|
||||||
|
let response = try await api.orderCheckIn(jsonBody: body)
|
||||||
|
onShowSignIn?(response)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performPhotographerVerify(jsonBody: String) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.orderVerify(jsonBody: jsonBody)
|
||||||
|
onShowWriteOff?(response)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performStoreVerify(parsed: QRCodeScanResult) async {
|
||||||
|
guard parsed.userId > 0, !parsed.orderNumber.isEmpty, !parsed.time.isEmpty else {
|
||||||
|
onShowMessage?("请扫描正确的核销码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let storeId = AppStore.shared.currentStoreId
|
||||||
|
guard storeId > 0 else {
|
||||||
|
onShowMessage?("店铺信息异常,请先选择店铺")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let response = try await api.storeOrderVerify(
|
||||||
|
StoreOrderVerifyRequest(
|
||||||
|
userId: parsed.userId,
|
||||||
|
orderNumber: parsed.orderNumber,
|
||||||
|
time: parsed.time,
|
||||||
|
storeId: storeId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onShowWriteOff?(response)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchSpotsForMultiVerify(pending: MultiVerifyParseData) async {
|
||||||
|
let status = locationManager.authorizationStatus
|
||||||
|
guard status == .authorizedWhenInUse || status == .authorizedAlways else {
|
||||||
|
locationManager.requestWhenInUseAuthorization()
|
||||||
|
onShowMessage?("缺少定位权限,功能无法进行")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let coordinate = try await requestLocation()
|
||||||
|
let response = try await api.multiTravelScenicSpotList(
|
||||||
|
orderNumber: pending.orderNumber,
|
||||||
|
lat: coordinate.latitude,
|
||||||
|
lng: coordinate.longitude
|
||||||
|
)
|
||||||
|
let spots = response.entityList
|
||||||
|
if spots.isEmpty {
|
||||||
|
onShowMessage?("暂无可核销的打卡点")
|
||||||
|
} else {
|
||||||
|
onShowMultiVerifySheet?(spots, spots.first)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestLocation() async throws -> CLLocationCoordinate2D {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
locationContinuation = { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let coordinate):
|
||||||
|
continuation.resume(returning: coordinate)
|
||||||
|
case .failure(let error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locationManager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
guard let location = locations.last else { return }
|
||||||
|
locationContinuation?(.success(location.coordinate))
|
||||||
|
locationContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
locationContinuation?(.failure(error))
|
||||||
|
locationContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension MainTabScanHandler {
|
||||||
|
func parseJSONObject(_ text: String) -> [String: Any]? {
|
||||||
|
guard let data = text.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
func serializeJSON(_ json: [String: Any]) -> String? {
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: json) else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
suixinkan/Features/Orders/Utils/OrderLocationProvider.swift
Normal file
55
suixinkan/Features/Orders/Utils/OrderLocationProvider.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// OrderLocationProvider.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// 一次性定位,用于多点位核销拉取打卡点。
|
||||||
|
final class OrderLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
private var continuation: ((Result<CLLocationCoordinate2D, Error>) -> Void)?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
manager.delegate = self
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||||
|
let status = manager.authorizationStatus
|
||||||
|
if status == .notDetermined {
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
guard status == .authorizedWhenInUse || status == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse else {
|
||||||
|
throw OrderLocationError.permissionDenied
|
||||||
|
}
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
self.continuation = { result in
|
||||||
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
manager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
guard let location = locations.last else { return }
|
||||||
|
continuation?(.success(location.coordinate))
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
continuation?(.failure(error))
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderLocationError: LocalizedError {
|
||||||
|
case permissionDenied
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .permissionDenied: "缺少定位权限,功能无法进行"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
suixinkan/Features/Orders/Utils/SaleUserQrParser.swift
Normal file
55
suixinkan/Features/Orders/Utils/SaleUserQrParser.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// SaleUserQrParser.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 获客员二维码解析,对齐 Android `SaleUserQrParser`。
|
||||||
|
enum SaleUserQrParser {
|
||||||
|
static let methodSaleUserBind = "sale_user_bind"
|
||||||
|
|
||||||
|
static func parseSaleUserId(_ content: String) -> Int? {
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
if let fromMethod = parseFromJSON(json) { return fromMethod }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let intValue = Int(trimmed), intValue > 0 { return intValue }
|
||||||
|
|
||||||
|
if trimmed.contains("://") || trimmed.contains("?"),
|
||||||
|
let components = URLComponents(string: trimmed) {
|
||||||
|
for key in ["sale_user_id", "saleUserId", "user_id", "id"] {
|
||||||
|
if let value = components.queryItems?.first(where: { $0.name == key })?.value,
|
||||||
|
let intValue = Int(value), intValue > 0 {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseFromJSON(_ json: [String: Any]) -> Int? {
|
||||||
|
if let method = json["method"] as? String, method == methodSaleUserBind,
|
||||||
|
let id = readInt(json["sale_user_id"]), id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
for key in ["sale_user_id", "saleUserId", "user_id", "id"] {
|
||||||
|
if let id = readInt(json[key]), id > 0 { return id }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readInt(_ value: Any?) -> Int? {
|
||||||
|
if let intValue = value as? Int { return intValue }
|
||||||
|
if let stringValue = value as? String, let intValue = Int(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
if let doubleValue = value as? Double { return Int(doubleValue) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
//
|
||||||
|
// DepositOrderListViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 店铺管理员押金单列表 ViewModel,对齐 Android `DepositOrderListViewModel`。
|
||||||
|
final class DepositOrderListViewModel {
|
||||||
|
|
||||||
|
private(set) var searchText = ""
|
||||||
|
private(set) var filterLabel = "全部"
|
||||||
|
private(set) var tabIndex = 0
|
||||||
|
private(set) var startTime: Date?
|
||||||
|
private(set) var endTime: Date?
|
||||||
|
private(set) var orderList: [StoreOrderItem] = []
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var canLoadMore = false
|
||||||
|
private(set) var isLoadingMore = false
|
||||||
|
private(set) var writeOffResponse: WriteOffOrderResponse?
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
private var currentPage = 1
|
||||||
|
private var totalCount = 0
|
||||||
|
private var searchDebounceTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private let apiDateFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.calendar = Calendar(identifier: .gregorian)
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
func refreshOrderList(api: OrderAPI) async {
|
||||||
|
currentPage = 1
|
||||||
|
isRefreshing = true
|
||||||
|
isLoadingMore = false
|
||||||
|
notifyStateChange()
|
||||||
|
await loadOrderList(refresh: true, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore(api: OrderAPI) async {
|
||||||
|
guard !isLoadingMore, !isRefreshing, orderList.count < totalCount else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
notifyStateChange()
|
||||||
|
await loadOrderList(refresh: false, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldLoadMore(at index: Int) -> Bool {
|
||||||
|
guard canLoadMore, !isRefreshing, !isLoadingMore else { return false }
|
||||||
|
return index >= max(0, orderList.count - 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onSearchTextChange(_ text: String, api: OrderAPI) {
|
||||||
|
searchText = text
|
||||||
|
notifyStateChange()
|
||||||
|
searchDebounceTask?.cancel()
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
Task { await refreshOrderList(api: api) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if Self.isValidPhone(trimmed) {
|
||||||
|
searchDebounceTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await refreshOrderList(api: api)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onFilterChange(tab: Int) {
|
||||||
|
tabIndex = tab
|
||||||
|
filterLabel = StoreOrderFilter.from(tabIndex: tab).label
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onDateRangeSelected(start: Date?, end: Date?) {
|
||||||
|
startTime = start
|
||||||
|
endTime = end
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissWriteOffDialog() {
|
||||||
|
writeOffResponse = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func processScanResult(_ scanResult: String, api: OrderAPI) async {
|
||||||
|
guard let parsed = QRCodeScanResult.parse(scanResult) else {
|
||||||
|
onShowMessage?("请扫描正确的二维码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard parsed.method == "order_verify" else {
|
||||||
|
onShowMessage?("请扫描订单核销码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard parsed.userId > 0, !parsed.orderNumber.isEmpty, !parsed.time.isEmpty else {
|
||||||
|
onShowMessage?("核销码内容不完整")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let storeId = AppStore.shared.currentStoreId
|
||||||
|
guard storeId > 0 else {
|
||||||
|
onShowMessage?("店铺信息异常,请先选择店铺")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await executeStoreOrderVerify(
|
||||||
|
StoreOrderVerifyRequest(
|
||||||
|
userId: parsed.userId,
|
||||||
|
orderNumber: parsed.orderNumber,
|
||||||
|
time: parsed.time,
|
||||||
|
storeId: storeId
|
||||||
|
),
|
||||||
|
api: api
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRefund(
|
||||||
|
orderNumber: String,
|
||||||
|
refundType: Int,
|
||||||
|
refundAmount: String,
|
||||||
|
refundReason: String,
|
||||||
|
api: OrderAPI
|
||||||
|
) async {
|
||||||
|
let storeId = AppStore.shared.currentStoreId
|
||||||
|
guard storeId > 0 else {
|
||||||
|
onShowMessage?("店铺信息异常")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await api.storeOrderRefund(
|
||||||
|
StoreOrderRefundRequest(
|
||||||
|
storeId: storeId,
|
||||||
|
orderNumber: orderNumber,
|
||||||
|
refundType: refundType,
|
||||||
|
refundAmount: refundAmount,
|
||||||
|
refundReason: refundReason
|
||||||
|
)
|
||||||
|
)
|
||||||
|
onShowMessage?("退款成功")
|
||||||
|
await refreshOrderList(api: api)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canOpenDetail(for item: StoreOrderItem) -> Bool {
|
||||||
|
item.orderType == StoreOrderType.multiTravelShoot
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isValidPhone(_ text: String) -> Bool {
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count == 11, trimmed.first == "1" else { return false }
|
||||||
|
guard let second = trimmed.dropFirst().first, ("3" ... "9").contains(second) else { return false }
|
||||||
|
return trimmed.allSatisfy(\.isNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func apiOrderStatus(forTabIndex tabIndex: Int) -> Int? {
|
||||||
|
OrderListQueryBuilder.storeOrderStatusQuery(tabIndex: tabIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadOrderList(refresh: Bool, api: OrderAPI) async {
|
||||||
|
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let isPhoneSearch = !phone.isEmpty && Self.isValidPhone(phone)
|
||||||
|
|
||||||
|
let storeId = AppStore.shared.currentStoreId
|
||||||
|
if storeId <= 0 {
|
||||||
|
finishLoading()
|
||||||
|
orderList = []
|
||||||
|
canLoadMore = false
|
||||||
|
onShowMessage?("店铺信息异常,请先选择店铺")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderStatus: Int?
|
||||||
|
let start: String?
|
||||||
|
let end: String?
|
||||||
|
let userPhone: String?
|
||||||
|
|
||||||
|
if isPhoneSearch {
|
||||||
|
orderStatus = 0
|
||||||
|
start = nil
|
||||||
|
end = nil
|
||||||
|
userPhone = phone
|
||||||
|
} else {
|
||||||
|
orderStatus = Self.apiOrderStatus(forTabIndex: tabIndex)
|
||||||
|
start = startTime.map { apiDateFormatter.string(from: $0) }
|
||||||
|
end = endTime.map { apiDateFormatter.string(from: $0) }
|
||||||
|
userPhone = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.storeOrderList(
|
||||||
|
storeId: storeId,
|
||||||
|
page: currentPage,
|
||||||
|
userPhone: userPhone,
|
||||||
|
orderStatus: orderStatus,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end
|
||||||
|
)
|
||||||
|
totalCount = response.total
|
||||||
|
if refresh || currentPage == 1 {
|
||||||
|
orderList = response.list
|
||||||
|
currentPage = 2
|
||||||
|
} else {
|
||||||
|
orderList.append(contentsOf: response.list)
|
||||||
|
currentPage += 1
|
||||||
|
}
|
||||||
|
canLoadMore = orderList.count < totalCount
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
finishLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func executeStoreOrderVerify(_ request: StoreOrderVerifyRequest, api: OrderAPI) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.storeOrderVerify(request)
|
||||||
|
writeOffResponse = response
|
||||||
|
if response.orderVerify {
|
||||||
|
await refreshOrderList(api: api)
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishLoading() {
|
||||||
|
isRefreshing = false
|
||||||
|
isLoadingMore = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
473
suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift
Normal file
473
suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
//
|
||||||
|
// OrderListViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct MultiVerifyParseData: Equatable {
|
||||||
|
let orderNumber: String
|
||||||
|
let userId: Int
|
||||||
|
let time: Int64
|
||||||
|
let checkSum: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OrderSourcePerson: Equatable {
|
||||||
|
let key: String
|
||||||
|
let name: String
|
||||||
|
let phone: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OrderSourceLead: Equatable {
|
||||||
|
let key: String
|
||||||
|
let displayText: String
|
||||||
|
let userMobile: String
|
||||||
|
let createdAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OrderSourceSelection: Equatable {
|
||||||
|
let person: OrderSourcePerson
|
||||||
|
let lead: OrderSourceLead
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摄影师/景区管理员订单列表 ViewModel,对齐 Android `OrderListViewModel`。
|
||||||
|
final class OrderListViewModel {
|
||||||
|
|
||||||
|
private(set) var orderList: [OrderEntity] = []
|
||||||
|
private(set) var searchText = ""
|
||||||
|
private(set) var filterLabel = "全部"
|
||||||
|
private(set) var tabIndex = 0
|
||||||
|
private(set) var rTabIndex = 0
|
||||||
|
private(set) var startTime: Date?
|
||||||
|
private(set) var endTime: Date?
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var canLoadMore = false
|
||||||
|
private(set) var isLoadingMore = false
|
||||||
|
|
||||||
|
private(set) var multiVerifyPendingData: MultiVerifyParseData?
|
||||||
|
private(set) var multiVerifyScenicSpotList: [MultiTravelScenicSpotEntity] = []
|
||||||
|
private(set) var selectedMultiVerifySpot: MultiTravelScenicSpotEntity?
|
||||||
|
private(set) var signInResponse: OrderSignInResponse?
|
||||||
|
private(set) var writeOffResponse: WriteOffOrderResponse?
|
||||||
|
|
||||||
|
private(set) var orderSourcePeople: [OrderSourcePerson] = []
|
||||||
|
private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:]
|
||||||
|
private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
||||||
|
private(set) var loadingOrderSourceLeadsFor: String?
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
private var currentPage = 1
|
||||||
|
private var totalCount = 0
|
||||||
|
private var loadingOrderSourcePeople = false
|
||||||
|
|
||||||
|
private let apiDateFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.calendar = Calendar(identifier: .gregorian)
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var isScenicAdminReadOnly: Bool {
|
||||||
|
AppStore.shared.currentAppRole == .scenicAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
var listAPIPath: String {
|
||||||
|
isScenicAdminReadOnly
|
||||||
|
? "/api/app/scenic-admin/order/list"
|
||||||
|
: "/api/yf-handset-app/photog/order/listv2"
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshOrderList(api: OrderAPI) async {
|
||||||
|
currentPage = 1
|
||||||
|
isRefreshing = true
|
||||||
|
isLoadingMore = false
|
||||||
|
orderList = []
|
||||||
|
notifyStateChange()
|
||||||
|
await loadOrderList(api: api, append: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore(api: OrderAPI) async {
|
||||||
|
guard !isLoadingMore, !isRefreshing else { return }
|
||||||
|
guard orderList.count < totalCount else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
currentPage += 1
|
||||||
|
notifyStateChange()
|
||||||
|
await loadOrderList(api: api, append: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldLoadMore(at index: Int) -> Bool {
|
||||||
|
guard canLoadMore, !isRefreshing, !isLoadingMore else { return false }
|
||||||
|
return index >= max(0, orderList.count - 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onSearchTextChange(_ text: String) {
|
||||||
|
searchText = text
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onFilterChange(orderStatus: Int, isRefined: Int) {
|
||||||
|
tabIndex = orderStatus
|
||||||
|
rTabIndex = isRefined
|
||||||
|
filterLabel = PhotographerOrderFilter.from(tabIndex: orderStatus, rTabIndex: isRefined).label
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onDateRangeSelected(start: Date?, end: Date?) {
|
||||||
|
startTime = start
|
||||||
|
endTime = end
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelOrder(orderNumber: String, api: OrderAPI) async {
|
||||||
|
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||||||
|
do {
|
||||||
|
try await api.cancelOrder(CancelRequest(orderNumber: orderNumber))
|
||||||
|
onShowMessage?("取消成功")
|
||||||
|
await refreshOrderList(api: api)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refundOrder(_ request: OrderRefundRequest, api: OrderAPI) async {
|
||||||
|
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||||||
|
do {
|
||||||
|
try await api.refundOrder(request)
|
||||||
|
onShowMessage?("退款成功")
|
||||||
|
await refreshOrderList(api: api)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int, api: OrderAPI) async {
|
||||||
|
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||||||
|
do {
|
||||||
|
try await api.giftRetouch(SendGiftRequest(orderNumber: orderNumber, photoNum: photoNum, videoNum: videoNum))
|
||||||
|
onShowMessage?("赠送成功")
|
||||||
|
await refreshOrderList(api: api)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRefined(orderNumber: String, isRefined: Int, api: OrderAPI) async {
|
||||||
|
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||||||
|
do {
|
||||||
|
try await api.updateRefined(orderNumber: orderNumber, isRefined: isRefined)
|
||||||
|
await refreshOrderList(api: api)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processScanResult(_ scanResult: String, api: OrderAPI) async {
|
||||||
|
if scanResult.isEmpty {
|
||||||
|
onShowMessage?("请扫描正确的二维码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||||||
|
|
||||||
|
if let parsed = QRCodeScanResult.parse(scanResult) {
|
||||||
|
switch parsed.method {
|
||||||
|
case "multi_verify":
|
||||||
|
guard !parsed.orderNumber.isEmpty, parsed.userId > 0, parsed.timeLong > 0, !parsed.checkSum.isEmpty else {
|
||||||
|
onShowMessage?("请扫描正确的多点位核销码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
multiVerifyPendingData = MultiVerifyParseData(
|
||||||
|
orderNumber: parsed.orderNumber,
|
||||||
|
userId: parsed.userId,
|
||||||
|
time: parsed.timeLong,
|
||||||
|
checkSum: parsed.checkSum
|
||||||
|
)
|
||||||
|
notifyStateChange()
|
||||||
|
return
|
||||||
|
case SaleUserQrParser.methodSaleUserBind:
|
||||||
|
if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 {
|
||||||
|
onShowMessage?("获客员绑定功能待接入")
|
||||||
|
} else {
|
||||||
|
onShowMessage?("请扫描正确的获客员二维码")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "order_verify":
|
||||||
|
await performOrderVerify(jsonBody: parsed.rawJSON, api: api)
|
||||||
|
return
|
||||||
|
case "check_in", "":
|
||||||
|
await performCheckIn(scanResult: scanResult, api: api)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await performCheckIn(scanResult: scanResult, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchScenicSpotsAndShowSheet(lat: Double, lng: Double, api: OrderAPI) async {
|
||||||
|
guard let data = multiVerifyPendingData else { return }
|
||||||
|
do {
|
||||||
|
let response = try await api.multiTravelScenicSpotList(
|
||||||
|
orderNumber: data.orderNumber,
|
||||||
|
lat: lat,
|
||||||
|
lng: lng
|
||||||
|
)
|
||||||
|
multiVerifyScenicSpotList = response.entityList
|
||||||
|
selectedMultiVerifySpot = multiVerifyScenicSpotList.first
|
||||||
|
notifyStateChange()
|
||||||
|
if multiVerifyScenicSpotList.isEmpty {
|
||||||
|
onShowMessage?("暂无可核销的打卡点")
|
||||||
|
clearMultiVerifyPending()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
clearMultiVerifyPending()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onMultiVerifySpotSelected(_ spot: MultiTravelScenicSpotEntity) {
|
||||||
|
selectedMultiVerifySpot = spot
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onMultiVerifyConfirm(api: OrderAPI) async {
|
||||||
|
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||||||
|
guard let data = multiVerifyPendingData, let spot = selectedMultiVerifySpot else {
|
||||||
|
onShowMessage?("请选择一个打卡点")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let response = try await api.multiTravelOrderVerify(
|
||||||
|
MultiTravelOrderVerifyRequest(
|
||||||
|
orderNumber: data.orderNumber,
|
||||||
|
userId: data.userId,
|
||||||
|
time: data.time,
|
||||||
|
checkSum: data.checkSum,
|
||||||
|
scenicSpotId: spot.scenicSpotId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
signInResponse = response
|
||||||
|
clearMultiVerifySheet()
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
clearMultiVerifyPending()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearMultiVerifySheet() {
|
||||||
|
multiVerifyScenicSpotList = []
|
||||||
|
selectedMultiVerifySpot = nil
|
||||||
|
multiVerifyPendingData = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearMultiVerifyPending() {
|
||||||
|
multiVerifyPendingData = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSignInResponse() {
|
||||||
|
signInResponse = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearWriteOffResponse() {
|
||||||
|
writeOffResponse = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldShowOrderSource(for order: OrderEntity) -> Bool {
|
||||||
|
guard !isScenicAdminReadOnly else { return false }
|
||||||
|
return order.orderType == OrderTypeCode.photographerFollow
|
||||||
|
|| order.orderType == OrderTypeCode.photographerFollowOfflineScan
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
|
||||||
|
if let selection = orderSourceSelections[order.orderNumber] { return selection }
|
||||||
|
guard let referral = order.referralOrder else { return nil }
|
||||||
|
let person = OrderSourcePerson(
|
||||||
|
key: String(referral.saleUserId),
|
||||||
|
name: referral.displaySalerName,
|
||||||
|
phone: referral.salerPhone
|
||||||
|
)
|
||||||
|
let lead = OrderSourceLead(
|
||||||
|
key: referral.orderNo,
|
||||||
|
displayText: referral.displayText.isEmpty ? referral.orderNo : referral.displayText,
|
||||||
|
userMobile: referral.userMobile,
|
||||||
|
createdAt: referral.createdAt
|
||||||
|
)
|
||||||
|
return OrderSourceSelection(person: person, lead: lead)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onOrderSourceSheetOpen(api: OrderAPI) async {
|
||||||
|
orderSourceLeads = [:]
|
||||||
|
guard !loadingOrderSourcePeople else { return }
|
||||||
|
loadingOrderSourcePeople = true
|
||||||
|
defer { loadingOrderSourcePeople = false }
|
||||||
|
let storeId = AppStore.shared.currentStoreId
|
||||||
|
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
||||||
|
do {
|
||||||
|
let response = try await api.cooperativeSalers(storeId: storeIdParam)
|
||||||
|
orderSourcePeople = response.list.map {
|
||||||
|
OrderSourcePerson(key: String($0.saleUserId), name: $0.displayName, phone: $0.displayPhone)
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrderSourceLeads(person: OrderSourcePerson, api: OrderAPI) async {
|
||||||
|
guard loadingOrderSourceLeadsFor == nil else { return }
|
||||||
|
loadingOrderSourceLeadsFor = person.key
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
loadingOrderSourceLeadsFor = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let response = try await api.referralOrders(saleUserId: person.key)
|
||||||
|
let leads = response.list.map {
|
||||||
|
OrderSourceLead(
|
||||||
|
key: $0.displayReferralOrderNo,
|
||||||
|
displayText: $0.displayReferralOrderNo,
|
||||||
|
userMobile: $0.userMobile,
|
||||||
|
createdAt: $0.createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
orderSourceLeads[person.key] = leads
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindOrderSource(orderNumber: String, selection: OrderSourceSelection, api: OrderAPI) async {
|
||||||
|
guard !abortIfScenicAdminReadOnlyMutation() else { return }
|
||||||
|
do {
|
||||||
|
try await api.bindReferralOrder(orderNumber: orderNumber, referralOrderNo: selection.lead.key)
|
||||||
|
orderSourceSelections[orderNumber] = selection
|
||||||
|
onShowMessage?("绑定成功")
|
||||||
|
await refreshOrderList(api: api)
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadOrderList(api: OrderAPI, append: Bool) async {
|
||||||
|
let scenicId = AppStore.shared.currentScenicId
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
finishLoading()
|
||||||
|
onShowMessage?("当前景区信息缺失")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = startTime.map { apiDateFormatter.string(from: $0) }
|
||||||
|
let end = endTime.map { apiDateFormatter.string(from: $0) }
|
||||||
|
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response: OrderListResponse<OrderEntity>
|
||||||
|
if isScenicAdminReadOnly {
|
||||||
|
response = try await api.scenicAdminOrderList(
|
||||||
|
scenicId: scenicId,
|
||||||
|
page: currentPage,
|
||||||
|
orderStatus: tabIndex,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
userPhone: phone.isEmpty ? nil : phone,
|
||||||
|
isRefined: rTabIndex
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
response = try await api.photographerOrderList(
|
||||||
|
scenicId: scenicId,
|
||||||
|
page: currentPage,
|
||||||
|
orderStatus: tabIndex,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
userPhone: phone.isEmpty ? nil : phone,
|
||||||
|
isRefined: rTabIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
totalCount = response.total
|
||||||
|
if append {
|
||||||
|
orderList.append(contentsOf: response.list)
|
||||||
|
} else {
|
||||||
|
orderList = response.list
|
||||||
|
}
|
||||||
|
syncOrderSourceSelections()
|
||||||
|
canLoadMore = orderList.count < totalCount
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
finishLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performCheckIn(scanResult: String, api: OrderAPI) async {
|
||||||
|
guard let jsonBody = appendScenicId(to: scanResult) else {
|
||||||
|
onShowMessage?("请扫描正确的二维码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
signInResponse = try await api.orderCheckIn(jsonBody: jsonBody)
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performOrderVerify(jsonBody: String, api: OrderAPI) async {
|
||||||
|
do {
|
||||||
|
writeOffResponse = try await api.orderVerify(jsonBody: jsonBody)
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendScenicId(to scanResult: String) -> String? {
|
||||||
|
guard let data = scanResult.data(using: .utf8),
|
||||||
|
var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
json["scenic_id"] = String(AppStore.shared.currentScenicId)
|
||||||
|
guard let updated = try? JSONSerialization.data(withJSONObject: json),
|
||||||
|
let string = String(data: updated, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncOrderSourceSelections() {
|
||||||
|
var updated = orderSourceSelections
|
||||||
|
for order in orderList {
|
||||||
|
if let selection = orderSourceSelection(for: order) {
|
||||||
|
updated[order.orderNumber] = selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orderSourceSelections = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func abortIfScenicAdminReadOnlyMutation() -> Bool {
|
||||||
|
guard isScenicAdminReadOnly else { return false }
|
||||||
|
onShowMessage?("景区管理员仅可查看")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishLoading() {
|
||||||
|
isRefreshing = false
|
||||||
|
isLoadingMore = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,10 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>需要访问相机以扫描订单核销二维码</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>需要定位以获取多点位核销打卡点列表</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>需要访问相册以选择头像、身份证和银行卡照片</string>
|
<string>需要访问相册以选择头像、身份证和银行卡照片</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
//
|
//
|
||||||
// StatisticsDateRangeViewController.swift
|
// DateRangePickerViewController.swift
|
||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 数据统计日期范围选择 Sheet,对齐 Android `DateRangeBottomSheet`。
|
/// 通用日期范围选择 Sheet,从 Statistics 抽取,对齐 Android `DateRangeBottomSheet`。
|
||||||
final class StatisticsDateRangeViewController: UIViewController {
|
final class DateRangePickerViewController: UIViewController {
|
||||||
|
|
||||||
var onShortcutSelected: ((String) -> Void)?
|
var onShortcutSelected: ((String) -> Void)?
|
||||||
var onRangeSelected: ((Date, Date) -> Void)?
|
var onRangeSelected: ((Date, Date) -> Void)?
|
||||||
@ -227,7 +227,7 @@ final class StatisticsDateRangeViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private final class DateCell: UICollectionViewCell {
|
private final class DateCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "StatisticsDateCell"
|
static let reuseIdentifier = "DateRangePickerDateCell"
|
||||||
|
|
||||||
private let label = UILabel()
|
private let label = UILabel()
|
||||||
|
|
||||||
@ -264,7 +264,7 @@ final class StatisticsDateRangeViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StatisticsDateRangeViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
extension DateRangePickerViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
monthDates().count
|
monthDates().count
|
||||||
}
|
}
|
||||||
@ -301,3 +301,6 @@ extension StatisticsDateRangeViewController: UICollectionViewDataSource, UIColle
|
|||||||
return CGSize(width: width, height: 32)
|
return CGSize(width: width, height: 32)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Statistics 模块兼容别名。
|
||||||
|
typealias StatisticsDateRangeViewController = DateRangePickerViewController
|
||||||
@ -12,6 +12,8 @@ final class MainTabBarController: UITabBarController {
|
|||||||
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
private var tabNavigationControllers: [AppTab: TabNavigationController] = [:]
|
||||||
private var isSyncingTabSelection = false
|
private var isSyncingTabSelection = false
|
||||||
|
|
||||||
|
private let scanHandler = MainTabScanHandler(api: NetworkServices.shared.orderAPI)
|
||||||
|
|
||||||
/// 测试时可注入扫码点击处理。
|
/// 测试时可注入扫码点击处理。
|
||||||
var onScanTabSelected: (() -> Void)?
|
var onScanTabSelected: (() -> Void)?
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ final class MainTabBarController: UITabBarController {
|
|||||||
configureTabBarAppearance()
|
configureTabBarAppearance()
|
||||||
configureTabs()
|
configureTabs()
|
||||||
bindBadgeViewModel()
|
bindBadgeViewModel()
|
||||||
|
bindScanHandler()
|
||||||
badgeViewModel.refreshPendingWriteOffCount()
|
badgeViewModel.refreshPendingWriteOffCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,10 +121,50 @@ final class MainTabBarController: UITabBarController {
|
|||||||
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
|
tabNavigationControllers[.orders]?.tabBarItem.badgeValue = badgeValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func bindScanHandler() {
|
||||||
|
scanHandler.onShowMessage = { [weak self] message in
|
||||||
|
self?.showScanToast(message)
|
||||||
|
}
|
||||||
|
scanHandler.onShowSignIn = { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
OrderSignInAlertController.present(from: self, response: response)
|
||||||
|
}
|
||||||
|
scanHandler.onShowWriteOff = { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
WriteOffResultAlertController.present(from: self, response: response)
|
||||||
|
}
|
||||||
|
scanHandler.onShowMultiVerifySheet = { [weak self] spots, selected in
|
||||||
|
self?.presentMultiVerifySheet(spots: spots, selected: selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showScanToast(_ message: String) {
|
||||||
|
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
||||||
|
present(alert, animated: true)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
|
alert.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentMultiVerifySheet(spots: [MultiTravelScenicSpotEntity], selected: MultiTravelScenicSpotEntity?) {
|
||||||
|
let controller = MultiVerifyScenicSpotViewController(spots: spots, selected: selected)
|
||||||
|
controller.onConfirm = { [weak self] spot in
|
||||||
|
guard let self, let pending = self.scanHandler.pendingMultiVerify else { return }
|
||||||
|
Task { await self.scanHandler.confirmMultiVerify(pending: pending, spot: spot) }
|
||||||
|
}
|
||||||
|
let nav = UINavigationController(rootViewController: controller)
|
||||||
|
nav.modalPresentationStyle = .pageSheet
|
||||||
|
present(nav, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func presentGlobalScanner() {
|
private func presentGlobalScanner() {
|
||||||
guard presentedViewController == nil else { return }
|
guard presentedViewController == nil else { return }
|
||||||
|
|
||||||
let scanner = ScanPlaceholderViewController()
|
let scanner = QRCodeScannerViewController()
|
||||||
|
scanner.onScanResult = { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.scanHandler.handleScanResult(result) }
|
||||||
|
}
|
||||||
let navigationController = UINavigationController(rootViewController: scanner)
|
let navigationController = UINavigationController(rootViewController: scanner)
|
||||||
navigationController.modalPresentationStyle = .fullScreen
|
navigationController.modalPresentationStyle = .fullScreen
|
||||||
present(navigationController, animated: true)
|
present(navigationController, animated: true)
|
||||||
|
|||||||
144
suixinkan/UI/Orders/DepositOrderCardCell.swift
Normal file
144
suixinkan/UI/Orders/DepositOrderCardCell.swift
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// DepositOrderCardCell.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct DepositOrderCardActions {
|
||||||
|
var onRefund: (() -> Void)?
|
||||||
|
var onPartialRefund: (() -> Void)?
|
||||||
|
var onVerify: (() -> Void)?
|
||||||
|
var onTap: (() -> Void)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 店铺订单卡片,对齐 Android `DepositOrderListScreen`。
|
||||||
|
final class DepositOrderCardCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "DepositOrderCardCell"
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let projectLabel = UILabel()
|
||||||
|
private let statusBadge = OrderStatusBadgeView()
|
||||||
|
private let amountLabel = UILabel()
|
||||||
|
private let timeLabel = UILabel()
|
||||||
|
private let uidLabel = UILabel()
|
||||||
|
private let phoneLabel = UILabel()
|
||||||
|
private let refundButton = UIButton(type: .system)
|
||||||
|
private let partialRefundButton = UIButton(type: .system)
|
||||||
|
private let verifyButton = UIButton(type: .system)
|
||||||
|
private var actions = DepositOrderCardActions()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(item: StoreOrderItem, actions: DepositOrderCardActions) {
|
||||||
|
self.actions = actions
|
||||||
|
projectLabel.text = item.projectName.isEmpty ? item.orderTypeLabel : item.projectName
|
||||||
|
statusBadge.apply(status: item.orderStatus, text: item.orderStatusName)
|
||||||
|
amountLabel.text = "¥\(item.actualPayAmount)"
|
||||||
|
timeLabel.text = item.createdAt
|
||||||
|
uidLabel.text = "UID:\(item.userId)"
|
||||||
|
phoneLabel.text = OrderPhoneMask.mask(item.phone)
|
||||||
|
|
||||||
|
let showVerify = item.orderType == StoreOrderType.offlineProduct && item.orderStatus == 20
|
||||||
|
verifyButton.isHidden = !showVerify
|
||||||
|
refundButton.isHidden = item.orderStatus != 20 && item.orderStatus != 30
|
||||||
|
partialRefundButton.isHidden = item.orderStatus != 20 && item.orderStatus != 30
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 12
|
||||||
|
let tap = UITapGestureRecognizer(target: self, action: #selector(cardTapped))
|
||||||
|
cardView.addGestureRecognizer(tap)
|
||||||
|
|
||||||
|
projectLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
amountLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
amountLabel.textColor = AppColor.primary
|
||||||
|
[timeLabel, uidLabel, phoneLabel].forEach {
|
||||||
|
$0.font = .systemFont(ofSize: 13)
|
||||||
|
$0.textColor = UIColor(hex: 0x6B7280)
|
||||||
|
}
|
||||||
|
|
||||||
|
configureActionButton(refundButton, title: "退款", color: UIColor(hex: 0xEF4444), bg: UIColor(hex: 0xFFE7E7))
|
||||||
|
configureActionButton(partialRefundButton, title: "部分退款", color: AppColor.primary, bg: UIColor(hex: 0xEFF6FF))
|
||||||
|
configureActionButton(verifyButton, title: "线下核销", color: .white, bg: UIColor(hex: 0x0073FF))
|
||||||
|
refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside)
|
||||||
|
partialRefundButton.addTarget(self, action: #selector(partialRefundTapped), for: .touchUpInside)
|
||||||
|
verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
[projectLabel, statusBadge, amountLabel, timeLabel, uidLabel, phoneLabel, refundButton, partialRefundButton, verifyButton].forEach {
|
||||||
|
cardView.addSubview($0)
|
||||||
|
}
|
||||||
|
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 12, right: 16))
|
||||||
|
}
|
||||||
|
projectLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
statusBadge.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(projectLabel)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
amountLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(projectLabel.snp.bottom).offset(8)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
timeLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(amountLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
uidLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(timeLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
phoneLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(uidLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
refundButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(phoneLabel.snp.bottom).offset(12)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(40)
|
||||||
|
make.width.equalTo(88)
|
||||||
|
make.bottom.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
partialRefundButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(refundButton)
|
||||||
|
make.leading.equalTo(refundButton.snp.trailing).offset(8)
|
||||||
|
make.height.equalTo(40)
|
||||||
|
make.width.equalTo(100)
|
||||||
|
}
|
||||||
|
verifyButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(refundButton)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(40)
|
||||||
|
make.width.equalTo(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureActionButton(_ button: UIButton, title: String, color: UIColor, bg: UIColor) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setTitleColor(color, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
button.backgroundColor = bg
|
||||||
|
button.layer.cornerRadius = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refundTapped() { actions.onRefund?() }
|
||||||
|
@objc private func partialRefundTapped() { actions.onPartialRefund?() }
|
||||||
|
@objc private func verifyTapped() { actions.onVerify?() }
|
||||||
|
@objc private func cardTapped() { actions.onTap?() }
|
||||||
|
}
|
||||||
88
suixinkan/UI/Orders/DepositOrderDetailViewController.swift
Normal file
88
suixinkan/UI/Orders/DepositOrderDetailViewController.swift
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// DepositOrderDetailViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 店铺多点位旅拍订单详情。
|
||||||
|
final class DepositOrderDetailViewController: BaseViewController, UITableViewDataSource {
|
||||||
|
|
||||||
|
private let orderNumber: String
|
||||||
|
private let orderAPI: OrderAPI
|
||||||
|
private var detail: StoreOrderDetailData?
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
|
|
||||||
|
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.orderAPI = orderAPI
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "订单详情"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
tableView.dataSource = self
|
||||||
|
view.addSubview(tableView)
|
||||||
|
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await loadDetail() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDetail() async {
|
||||||
|
let storeId = AppStore.shared.currentStoreId
|
||||||
|
guard storeId > 0 else {
|
||||||
|
showToast("店铺信息异常")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showLoading()
|
||||||
|
defer { hideLoading() }
|
||||||
|
do {
|
||||||
|
detail = try await orderAPI.storeOrderDetail(storeId: storeId, orderNumber: orderNumber)
|
||||||
|
tableView.reloadData()
|
||||||
|
} catch {
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
if section == 0 { return 6 }
|
||||||
|
return detail?.multiTravel?.shootingList.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
|
section == 0 ? "基本信息" : "拍摄点位"
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||||
|
guard let detail else { return cell }
|
||||||
|
if indexPath.section == 0 {
|
||||||
|
switch indexPath.row {
|
||||||
|
case 0: cell.textLabel?.text = "订单号"; cell.detailTextLabel?.text = detail.orderNumber
|
||||||
|
case 1: cell.textLabel?.text = "项目"; cell.detailTextLabel?.text = detail.projectName
|
||||||
|
case 2: cell.textLabel?.text = "状态"; cell.detailTextLabel?.text = detail.orderStatusName
|
||||||
|
case 3: cell.textLabel?.text = "金额"; cell.detailTextLabel?.text = "¥\(detail.actualPayAmount)"
|
||||||
|
case 4: cell.textLabel?.text = "手机号"; cell.detailTextLabel?.text = OrderPhoneMask.mask(detail.phone)
|
||||||
|
default: cell.textLabel?.text = "创建时间"; cell.detailTextLabel?.text = detail.createdAt
|
||||||
|
}
|
||||||
|
} else if let item = detail.multiTravel?.shootingList[indexPath.row] {
|
||||||
|
cell.textLabel?.text = item.scenicSpotName
|
||||||
|
cell.detailTextLabel?.text = item.displayStaffName
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// HistoricalShootingInfoViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 历史拍摄信息详情页。
|
||||||
|
final class HistoricalShootingInfoViewController: BaseViewController, UITableViewDataSource {
|
||||||
|
|
||||||
|
private let orderNumber: String
|
||||||
|
private let orderAPI: OrderAPI
|
||||||
|
private var response: MultiTravelShootHistoryResponse?
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
|
|
||||||
|
init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
|
||||||
|
self.orderNumber = orderNumber
|
||||||
|
self.orderAPI = orderAPI
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "历史拍摄"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
tableView.dataSource = self
|
||||||
|
view.addSubview(tableView)
|
||||||
|
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await loadData() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadData() async {
|
||||||
|
showLoading()
|
||||||
|
defer { hideLoading() }
|
||||||
|
do {
|
||||||
|
response = try await orderAPI.shootHistory(orderNumber: orderNumber)
|
||||||
|
tableView.reloadData()
|
||||||
|
} catch {
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfSections(in tableView: UITableView) -> Int { 1 }
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
response?.photogSpotList.count ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||||
|
guard let spot = response?.photogSpotList[indexPath.row] else { return cell }
|
||||||
|
cell.textLabel?.text = spot.scenicSpotName
|
||||||
|
cell.detailTextLabel?.text = "\(spot.photographerDisplayName) · \(spot.files.count)个素材"
|
||||||
|
cell.accessoryType = .disclosureIndicator
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
//
|
||||||
|
// MultiVerifyScenicSpotViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 多点位核销打卡点选择 Sheet。
|
||||||
|
final class MultiVerifyScenicSpotViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
|
||||||
|
var onConfirm: ((MultiTravelScenicSpotEntity) -> Void)?
|
||||||
|
var onCancel: (() -> Void)?
|
||||||
|
|
||||||
|
private var spots: [MultiTravelScenicSpotEntity]
|
||||||
|
private var selectedSpot: MultiTravelScenicSpotEntity?
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
init(spots: [MultiTravelScenicSpotEntity], selected: MultiTravelScenicSpotEntity?) {
|
||||||
|
self.spots = spots
|
||||||
|
self.selectedSpot = selected ?? spots.first
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = .white
|
||||||
|
title = "选择打卡点"
|
||||||
|
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(cancelTapped))
|
||||||
|
|
||||||
|
tableView.dataSource = self
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "spot")
|
||||||
|
|
||||||
|
confirmButton.setTitle("确认核销", for: .normal)
|
||||||
|
confirmButton.backgroundColor = AppColor.primary
|
||||||
|
confirmButton.setTitleColor(.white, for: .normal)
|
||||||
|
confirmButton.layer.cornerRadius = 8
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(confirmButton)
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.bottom.equalTo(confirmButton.snp.top).offset(-12)
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
onCancel?()
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
guard let selectedSpot else { return }
|
||||||
|
onConfirm?(selectedSpot)
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
spots.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: "spot", for: indexPath)
|
||||||
|
let spot = spots[indexPath.row]
|
||||||
|
var config = cell.defaultContentConfiguration()
|
||||||
|
config.text = spot.name
|
||||||
|
config.secondaryText = "\(spot.scenicName) · \(spot.distanceText)"
|
||||||
|
cell.contentConfiguration = config
|
||||||
|
cell.accessoryType = spot.scenicSpotId == selectedSpot?.scenicSpotId ? .checkmark : .none
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
selectedSpot = spots[indexPath.row]
|
||||||
|
tableView.reloadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
314
suixinkan/UI/Orders/OrderCardCell.swift
Normal file
314
suixinkan/UI/Orders/OrderCardCell.swift
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
//
|
||||||
|
// OrderCardCell.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct OrderCardActions {
|
||||||
|
var onRefund: (() -> Void)?
|
||||||
|
var onVerify: (() -> Void)?
|
||||||
|
var onUploadTask: (() -> Void)?
|
||||||
|
var onCancel: (() -> Void)?
|
||||||
|
var onCallPhone: (() -> Void)?
|
||||||
|
var onGiftPhotoMinus: (() -> Void)?
|
||||||
|
var onGiftPhotoPlus: (() -> Void)?
|
||||||
|
var onGiftVideoMinus: (() -> Void)?
|
||||||
|
var onGiftVideoPlus: (() -> Void)?
|
||||||
|
var onSendGift: (() -> Void)?
|
||||||
|
var onRefinedChanged: ((Int) -> Void)?
|
||||||
|
var onHistoricalPhoto: (() -> Void)?
|
||||||
|
var onOrderSourceTap: (() -> Void)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摄影师订单卡片,对齐 Android `OrderView`。
|
||||||
|
final class OrderCardCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "OrderCardCell"
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let orderNumberLabel = UILabel()
|
||||||
|
private let statusBadge = OrderStatusBadgeView()
|
||||||
|
private let createdAtLabel = UILabel()
|
||||||
|
private let typeChip = UILabel()
|
||||||
|
private let actionStack = UIStackView()
|
||||||
|
private let refundButton = UIButton(type: .system)
|
||||||
|
private let verifyButton = UIButton(type: .system)
|
||||||
|
private let uploadTaskButton = UIButton(type: .system)
|
||||||
|
private let infoStack = UIStackView()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let orderSourceButton = UIButton(type: .system)
|
||||||
|
private let historicalButton = UIButton(type: .system)
|
||||||
|
private let refinedStack = UIStackView()
|
||||||
|
private let refinedNeedButton = UIButton(type: .system)
|
||||||
|
private let refinedNoButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
private var actions = OrderCardActions()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(order: OrderEntity, hideWriteActions: Bool, actions: OrderCardActions, showOrderSource: Bool, sourceText: String?) {
|
||||||
|
self.actions = actions
|
||||||
|
orderNumberLabel.text = "订单号:\(order.orderNumber)"
|
||||||
|
statusBadge.apply(status: order.orderStatus, text: order.orderStatusName)
|
||||||
|
createdAtLabel.text = order.createdAt.isEmpty ? "--" : order.createdAt
|
||||||
|
typeChip.text = order.orderTypeLabel
|
||||||
|
typeChip.isHidden = order.orderTypeLabel.isEmpty
|
||||||
|
|
||||||
|
let showRefund = !hideWriteActions && (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19
|
||||||
|
let showVerify = !hideWriteActions && (
|
||||||
|
(order.photoTravel?.needCheckIn == true && order.orderStatus != 30) || order.orderType == 19
|
||||||
|
)
|
||||||
|
refundButton.isHidden = !showRefund
|
||||||
|
verifyButton.isHidden = !showVerify
|
||||||
|
actionStack.isHidden = !showRefund && !showVerify
|
||||||
|
uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19
|
||||||
|
|
||||||
|
orderSourceButton.isHidden = !showOrderSource
|
||||||
|
orderSourceButton.setTitle(sourceText ?? "选择订单来源", for: .normal)
|
||||||
|
|
||||||
|
let showRefined = order.orderStatus == 18 || order.orderStatus == 30
|
||||||
|
refinedStack.isHidden = !showRefined
|
||||||
|
updateRefinedButtons(isRefined: order.isRefined, readOnly: hideWriteActions)
|
||||||
|
|
||||||
|
cancelButton.isHidden = hideWriteActions || order.photoTravel?.canCancelOrder != true
|
||||||
|
|
||||||
|
infoStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
addInfoRow(title: "付款金额", value: "¥\(order.actualPayAmount)")
|
||||||
|
if !order.payTime.isEmpty { addInfoRow(title: "付款时间", value: order.payTime) }
|
||||||
|
if !order.completeTime.isEmpty { addInfoRow(title: "完成时间", value: order.completeTime) }
|
||||||
|
if !order.payTypeName.isEmpty { addInfoRow(title: "付款方式", value: order.payTypeName) }
|
||||||
|
addInfoRow(title: "UID", value: String(order.userId))
|
||||||
|
addPhoneRow(phone: order.phone, hidden: hideWriteActions)
|
||||||
|
if let travel = order.photoTravel, !travel.checkInTime.isEmpty {
|
||||||
|
addInfoRow(title: "核销时间", value: travel.checkInTime)
|
||||||
|
}
|
||||||
|
if let travel = order.photoTravel {
|
||||||
|
addInfoRow(title: "修图张数", value: "\(travel.orderPhotoNum)")
|
||||||
|
addInfoRow(title: "视频", value: "\(travel.orderVideoNum)")
|
||||||
|
}
|
||||||
|
if let remark = order.remark, !remark.isEmpty {
|
||||||
|
addInfoRow(title: "备注", value: remark)
|
||||||
|
}
|
||||||
|
|
||||||
|
let materials = order.multiTravel?.materialList ?? []
|
||||||
|
historicalButton.isHidden = materials.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 12
|
||||||
|
|
||||||
|
orderNumberLabel.font = .systemFont(ofSize: 14)
|
||||||
|
orderNumberLabel.textColor = .black
|
||||||
|
createdAtLabel.font = .systemFont(ofSize: 12)
|
||||||
|
createdAtLabel.textColor = UIColor(hex: 0x6B7280)
|
||||||
|
typeChip.font = .systemFont(ofSize: 12)
|
||||||
|
typeChip.textColor = AppColor.primary
|
||||||
|
typeChip.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||||
|
typeChip.layer.cornerRadius = 4
|
||||||
|
typeChip.clipsToBounds = true
|
||||||
|
typeChip.textAlignment = .center
|
||||||
|
|
||||||
|
configureActionButton(refundButton, title: "退款", color: UIColor(hex: 0xEF4444), bg: UIColor(hex: 0xFFE7E7))
|
||||||
|
configureActionButton(verifyButton, title: "核销订单", color: .white, bg: UIColor(hex: 0x0073FF))
|
||||||
|
refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside)
|
||||||
|
verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
uploadTaskButton.setTitle("上传任务", for: .normal)
|
||||||
|
uploadTaskButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
uploadTaskButton.titleLabel?.font = .systemFont(ofSize: 16)
|
||||||
|
uploadTaskButton.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||||
|
uploadTaskButton.layer.cornerRadius = 12
|
||||||
|
uploadTaskButton.addTarget(self, action: #selector(uploadTaskTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
actionStack.axis = .horizontal
|
||||||
|
actionStack.spacing = 12
|
||||||
|
actionStack.distribution = .fillEqually
|
||||||
|
actionStack.addArrangedSubview(refundButton)
|
||||||
|
actionStack.addArrangedSubview(verifyButton)
|
||||||
|
|
||||||
|
infoStack.axis = .vertical
|
||||||
|
infoStack.spacing = 6
|
||||||
|
|
||||||
|
cancelButton.setTitle("取消订单", for: .normal)
|
||||||
|
cancelButton.setTitleColor(UIColor(hex: 0xEF4444), for: .normal)
|
||||||
|
cancelButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
orderSourceButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
orderSourceButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
orderSourceButton.contentHorizontalAlignment = .leading
|
||||||
|
orderSourceButton.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
historicalButton.setTitle("历史拍摄", for: .normal)
|
||||||
|
historicalButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
historicalButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
historicalButton.addTarget(self, action: #selector(historicalTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
refinedStack.axis = .horizontal
|
||||||
|
refinedStack.spacing = 8
|
||||||
|
refinedNeedButton.setTitle("需要精修", for: .normal)
|
||||||
|
refinedNoButton.setTitle("无需精修", for: .normal)
|
||||||
|
[refinedNeedButton, refinedNoButton].forEach {
|
||||||
|
$0.titleLabel?.font = .systemFont(ofSize: 13)
|
||||||
|
$0.layer.cornerRadius = 6
|
||||||
|
$0.layer.borderWidth = 1
|
||||||
|
}
|
||||||
|
refinedNeedButton.addTarget(self, action: #selector(refinedNeedTapped), for: .touchUpInside)
|
||||||
|
refinedNoButton.addTarget(self, action: #selector(refinedNoTapped), for: .touchUpInside)
|
||||||
|
refinedStack.addArrangedSubview(refinedNeedButton)
|
||||||
|
refinedStack.addArrangedSubview(refinedNoButton)
|
||||||
|
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(orderNumberLabel)
|
||||||
|
cardView.addSubview(statusBadge)
|
||||||
|
cardView.addSubview(createdAtLabel)
|
||||||
|
cardView.addSubview(typeChip)
|
||||||
|
cardView.addSubview(orderSourceButton)
|
||||||
|
cardView.addSubview(actionStack)
|
||||||
|
cardView.addSubview(uploadTaskButton)
|
||||||
|
cardView.addSubview(refinedStack)
|
||||||
|
cardView.addSubview(infoStack)
|
||||||
|
cardView.addSubview(historicalButton)
|
||||||
|
cardView.addSubview(cancelButton)
|
||||||
|
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 12, right: 16))
|
||||||
|
}
|
||||||
|
orderNumberLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
statusBadge.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(orderNumberLabel)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
createdAtLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(orderNumberLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
typeChip.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(createdAtLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(20)
|
||||||
|
make.width.greaterThanOrEqualTo(40)
|
||||||
|
}
|
||||||
|
orderSourceButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(typeChip.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
actionStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(orderSourceButton.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
uploadTaskButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(actionStack.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
refinedStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(uploadTaskButton.snp.bottom).offset(12)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
infoStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(refinedStack.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
historicalButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(infoStack.snp.bottom).offset(8)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(historicalButton.snp.bottom).offset(12)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
make.bottom.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureActionButton(_ button: UIButton, title: String, color: UIColor, bg: UIColor) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setTitleColor(color, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 16)
|
||||||
|
button.backgroundColor = bg
|
||||||
|
button.layer.cornerRadius = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addInfoRow(title: String, value: String) {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.distribution = .fillEqually
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .systemFont(ofSize: 13)
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x6B7280)
|
||||||
|
let valueLabel = UILabel()
|
||||||
|
valueLabel.text = value
|
||||||
|
valueLabel.font = .systemFont(ofSize: 13)
|
||||||
|
valueLabel.textColor = AppColor.text333
|
||||||
|
valueLabel.textAlignment = .right
|
||||||
|
row.addArrangedSubview(titleLabel)
|
||||||
|
row.addArrangedSubview(valueLabel)
|
||||||
|
infoStack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addPhoneRow(phone: String, hidden: Bool) {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.distribution = .fillEqually
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = "手机号"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 13)
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x6B7280)
|
||||||
|
let valueButton = UIButton(type: .system)
|
||||||
|
valueButton.setTitle(OrderPhoneMask.mask(phone), for: .normal)
|
||||||
|
valueButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
valueButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||||
|
valueButton.contentHorizontalAlignment = .trailing
|
||||||
|
valueButton.isHidden = hidden || phone.isEmpty
|
||||||
|
valueButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
||||||
|
row.addArrangedSubview(titleLabel)
|
||||||
|
row.addArrangedSubview(valueButton)
|
||||||
|
infoStack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRefinedButtons(isRefined: Int, readOnly: Bool) {
|
||||||
|
let selectedBorder = AppColor.primary.cgColor
|
||||||
|
let normalBorder = UIColor(hex: 0xE5E7EB).cgColor
|
||||||
|
refinedNeedButton.layer.borderColor = isRefined == 1 ? selectedBorder : normalBorder
|
||||||
|
refinedNoButton.layer.borderColor = isRefined == 2 ? selectedBorder : normalBorder
|
||||||
|
refinedNeedButton.isEnabled = !readOnly
|
||||||
|
refinedNoButton.isEnabled = !readOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refundTapped() { actions.onRefund?() }
|
||||||
|
@objc private func verifyTapped() { actions.onVerify?() }
|
||||||
|
@objc private func uploadTaskTapped() { actions.onUploadTask?() }
|
||||||
|
@objc private func cancelTapped() { actions.onCancel?() }
|
||||||
|
@objc private func callTapped() { actions.onCallPhone?() }
|
||||||
|
@objc private func orderSourceTapped() { actions.onOrderSourceTap?() }
|
||||||
|
@objc private func historicalTapped() { actions.onHistoricalPhoto?() }
|
||||||
|
@objc private func refinedNeedTapped() { actions.onRefinedChanged?(1) }
|
||||||
|
@objc private func refinedNoTapped() { actions.onRefinedChanged?(2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderPhoneMask {
|
||||||
|
static func mask(_ phone: String) -> String {
|
||||||
|
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.count >= 7 else { return trimmed }
|
||||||
|
let start = trimmed.prefix(3)
|
||||||
|
let end = trimmed.suffix(4)
|
||||||
|
return "\(start)****\(end)"
|
||||||
|
}
|
||||||
|
}
|
||||||
95
suixinkan/UI/Orders/OrderDialogControllers.swift
Normal file
95
suixinkan/UI/Orders/OrderDialogControllers.swift
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// OrderDialogControllers.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum OrderRefundAlertController {
|
||||||
|
static func present(
|
||||||
|
from presenter: UIViewController,
|
||||||
|
order: OrderEntity,
|
||||||
|
onConfirm: @escaping (OrderRefundRequest) -> Void
|
||||||
|
) {
|
||||||
|
let alert = UIAlertController(title: "订单退款", message: order.orderNumber, preferredStyle: .alert)
|
||||||
|
alert.addTextField { $0.placeholder = "退款金额"; $0.text = order.actualPayAmount }
|
||||||
|
alert.addTextField { $0.placeholder = "退款原因" }
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "确认退款", style: .destructive) { _ in
|
||||||
|
let amount = alert.textFields?.first?.text ?? order.actualPayAmount
|
||||||
|
let reason = alert.textFields?.last?.text ?? ""
|
||||||
|
onConfirm(OrderRefundRequest(orderNumber: order.orderNumber, refundType: 1, refundAmount: amount, refundReason: reason))
|
||||||
|
})
|
||||||
|
presenter.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DepositOrderRefundAlertController {
|
||||||
|
static func present(
|
||||||
|
from presenter: UIViewController,
|
||||||
|
orderNumber: String,
|
||||||
|
amount: String,
|
||||||
|
refundType: Int,
|
||||||
|
onConfirm: @escaping (String, String) -> Void
|
||||||
|
) {
|
||||||
|
let title = refundType == 3 ? "部分退款" : "全额退款"
|
||||||
|
let alert = UIAlertController(title: title, message: orderNumber, preferredStyle: .alert)
|
||||||
|
alert.addTextField { $0.placeholder = "退款金额"; $0.text = amount }
|
||||||
|
alert.addTextField { $0.placeholder = "退款原因" }
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "确认", style: .destructive) { _ in
|
||||||
|
let refundAmount = alert.textFields?.first?.text ?? amount
|
||||||
|
let reason = alert.textFields?.last?.text ?? ""
|
||||||
|
onConfirm(refundAmount, reason)
|
||||||
|
})
|
||||||
|
presenter.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OrderSignInAlertController {
|
||||||
|
static func present(from presenter: UIViewController, response: OrderSignInResponse) {
|
||||||
|
let message = """
|
||||||
|
项目:\(response.projectName)
|
||||||
|
景区:\(response.scenicName)
|
||||||
|
金额:¥\(response.orderAmount)
|
||||||
|
手机号:\(response.userPhone)
|
||||||
|
"""
|
||||||
|
let alert = UIAlertController(title: response.checkIn ? "签到成功" : "核销结果", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "确定", style: .default))
|
||||||
|
presenter.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WriteOffResultAlertController {
|
||||||
|
static func present(from presenter: UIViewController, response: WriteOffOrderResponse) {
|
||||||
|
let message = """
|
||||||
|
项目:\(response.projectName)
|
||||||
|
景区:\(response.scenicName)
|
||||||
|
金额:¥\(response.orderAmount)
|
||||||
|
手机号:\(response.userPhone)
|
||||||
|
"""
|
||||||
|
let alert = UIAlertController(title: response.orderVerify ? "核销成功" : "核销结果", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "确定", style: .default))
|
||||||
|
presenter.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SendGiftAlertController {
|
||||||
|
static func present(
|
||||||
|
from presenter: UIViewController,
|
||||||
|
photoNum: Int,
|
||||||
|
videoNum: Int,
|
||||||
|
onConfirm: @escaping (Int, Int) -> Void
|
||||||
|
) {
|
||||||
|
let alert = UIAlertController(title: "赠送修图", message: nil, preferredStyle: .alert)
|
||||||
|
alert.addTextField { $0.placeholder = "赠送照片数"; $0.keyboardType = .numberPad; $0.text = "\(photoNum)" }
|
||||||
|
alert.addTextField { $0.placeholder = "赠送视频数"; $0.keyboardType = .numberPad; $0.text = "\(videoNum)" }
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in
|
||||||
|
let photo = Int(alert.textFields?.first?.text ?? "0") ?? 0
|
||||||
|
let video = Int(alert.textFields?.last?.text ?? "0") ?? 0
|
||||||
|
onConfirm(photo, video)
|
||||||
|
})
|
||||||
|
presenter.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
suixinkan/UI/Orders/OrderFilterBarView.swift
Normal file
134
suixinkan/UI/Orders/OrderFilterBarView.swift
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
//
|
||||||
|
// OrderFilterBarView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 订单筛选栏:搜索 + 状态下拉 + 日期筛选。
|
||||||
|
final class OrderFilterBarView: UIView {
|
||||||
|
|
||||||
|
var onSearchChanged: ((String) -> Void)?
|
||||||
|
var onSearchSubmitted: (() -> Void)?
|
||||||
|
var onFilterSelected: ((Int, Int) -> Void)?
|
||||||
|
var onDateRangeTap: (() -> Void)?
|
||||||
|
|
||||||
|
private let searchField = UITextField()
|
||||||
|
private let filterButton = UIButton(type: .system)
|
||||||
|
private let dateButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
private var isStoreMode = false
|
||||||
|
private var currentFilterLabel = "全部"
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = .white
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(isStoreMode: Bool, filterLabel: String, dateRangeText: String, searchText: String) {
|
||||||
|
self.isStoreMode = isStoreMode
|
||||||
|
currentFilterLabel = filterLabel
|
||||||
|
searchField.placeholder = isStoreMode ? "输入游客手机号" : "输入手机号搜索"
|
||||||
|
searchField.text = searchText
|
||||||
|
filterButton.setTitle("\(filterLabel) ▾", for: .normal)
|
||||||
|
dateButton.setTitle(dateRangeText, for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
searchField.font = .systemFont(ofSize: 14)
|
||||||
|
searchField.keyboardType = .phonePad
|
||||||
|
searchField.returnKeyType = .search
|
||||||
|
searchField.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
|
searchField.layer.cornerRadius = 4
|
||||||
|
searchField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 12))
|
||||||
|
searchField.leftViewMode = .always
|
||||||
|
searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged)
|
||||||
|
searchField.delegate = self
|
||||||
|
|
||||||
|
filterButton.setTitleColor(AppColor.text333, for: .normal)
|
||||||
|
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
dateButton.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
dateButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
|
dateButton.contentHorizontalAlignment = .trailing
|
||||||
|
dateButton.addTarget(self, action: #selector(dateTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
addSubview(searchField)
|
||||||
|
addSubview(filterButton)
|
||||||
|
addSubview(dateButton)
|
||||||
|
|
||||||
|
searchField.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.height.equalTo(36)
|
||||||
|
}
|
||||||
|
filterButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(searchField.snp.bottom).offset(12)
|
||||||
|
make.leading.equalToSuperview().inset(16)
|
||||||
|
make.bottom.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
dateButton.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(filterButton)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func searchChanged() {
|
||||||
|
onSearchChanged?(searchField.text ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func filterTapped() {
|
||||||
|
if isStoreMode {
|
||||||
|
presentStoreFilterMenu()
|
||||||
|
} else {
|
||||||
|
presentPhotographerFilterMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dateTapped() {
|
||||||
|
onDateRangeTap?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentPhotographerFilterMenu() {
|
||||||
|
let actions: [(String, Int, Int)] = PhotographerOrderFilter.allCases.map {
|
||||||
|
($0.label, $0.tabIndex, $0.rTabIndex)
|
||||||
|
}
|
||||||
|
presentMenu(actions: actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentStoreFilterMenu() {
|
||||||
|
let actions: [(String, Int, Int)] = StoreOrderFilter.allCases.map {
|
||||||
|
($0.label, $0.tabIndex, 0)
|
||||||
|
}
|
||||||
|
presentMenu(actions: actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentMenu(actions: [(String, Int, Int)]) {
|
||||||
|
guard let presenter = window?.rootViewController?.presentedViewController ?? window?.rootViewController else { return }
|
||||||
|
let alert = UIAlertController(title: "筛选订单", message: nil, preferredStyle: .actionSheet)
|
||||||
|
actions.forEach { title, tab, rTab in
|
||||||
|
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||||
|
self?.currentFilterLabel = title
|
||||||
|
self?.filterButton.setTitle("\(title) ▾", for: .normal)
|
||||||
|
self?.onFilterSelected?(tab, rTab)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
presenter.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OrderFilterBarView: UITextFieldDelegate {
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
onSearchSubmitted?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
73
suixinkan/UI/Orders/OrderListView.swift
Normal file
73
suixinkan/UI/Orders/OrderListView.swift
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// OrderListView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 订单列表容器,含下拉刷新与分页。
|
||||||
|
final class OrderListView: UIView {
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case photographer
|
||||||
|
case store
|
||||||
|
}
|
||||||
|
|
||||||
|
var onRefresh: (() -> Void)?
|
||||||
|
var onLoadMore: ((Int) -> Void)?
|
||||||
|
|
||||||
|
let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let refreshControl = UIRefreshControl()
|
||||||
|
private let footerLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.keyboardDismissMode = .onDrag
|
||||||
|
tableView.refreshControl = refreshControl
|
||||||
|
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
||||||
|
|
||||||
|
footerLabel.font = .systemFont(ofSize: 12)
|
||||||
|
footerLabel.textColor = UIColor(hex: 0x9CA3AF)
|
||||||
|
footerLabel.textAlignment = .center
|
||||||
|
footerLabel.text = "没有更多"
|
||||||
|
|
||||||
|
addSubview(tableView)
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(isRefreshing: Bool, canLoadMore: Bool, isEmpty: Bool) {
|
||||||
|
if isRefreshing {
|
||||||
|
if !refreshControl.isRefreshing { refreshControl.beginRefreshing() }
|
||||||
|
} else {
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
}
|
||||||
|
footerLabel.text = isEmpty ? "暂无订单" : (canLoadMore ? "" : "没有更多")
|
||||||
|
tableView.tableFooterView = footerLabelFrame(isEmpty: isEmpty, canLoadMore: canLoadMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func footerLabelFrame(isEmpty: Bool, canLoadMore: Bool) -> UIView? {
|
||||||
|
guard !canLoadMore || isEmpty else { return nil }
|
||||||
|
footerLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 44)
|
||||||
|
return footerLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshTriggered() {
|
||||||
|
onRefresh?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyLoadMoreIfNeeded(at index: Int, shouldLoad: Bool) {
|
||||||
|
guard shouldLoad else { return }
|
||||||
|
onLoadMore?(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
suixinkan/UI/Orders/OrderSourcePickerViewController.swift
Normal file
76
suixinkan/UI/Orders/OrderSourcePickerViewController.swift
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
//
|
||||||
|
// OrderSourcePickerViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 订单来源选择器。
|
||||||
|
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
|
||||||
|
var onPersonSelected: ((OrderSourcePerson) -> Void)?
|
||||||
|
var onLeadSelected: ((OrderSourceSelection) -> Void)?
|
||||||
|
|
||||||
|
private let people: [OrderSourcePerson]
|
||||||
|
private let leads: [OrderSourceLead]
|
||||||
|
private let selectedPerson: OrderSourcePerson?
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||||
|
private var showingLeads = false
|
||||||
|
|
||||||
|
init(people: [OrderSourcePerson], leads: [OrderSourceLead], selectedPerson: OrderSourcePerson?) {
|
||||||
|
self.people = people
|
||||||
|
self.leads = leads
|
||||||
|
self.selectedPerson = selectedPerson
|
||||||
|
self.showingLeads = !leads.isEmpty
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
title = showingLeads ? "选择带客单" : "选择获客员"
|
||||||
|
view.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped))
|
||||||
|
tableView.dataSource = self
|
||||||
|
tableView.delegate = self
|
||||||
|
view.addSubview(tableView)
|
||||||
|
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closeTapped() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
showingLeads ? leads.count : people.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||||
|
if showingLeads {
|
||||||
|
let lead = leads[indexPath.row]
|
||||||
|
cell.textLabel?.text = lead.displayText
|
||||||
|
cell.detailTextLabel?.text = lead.userMobile
|
||||||
|
} else {
|
||||||
|
let person = people[indexPath.row]
|
||||||
|
cell.textLabel?.text = person.name
|
||||||
|
cell.detailTextLabel?.text = person.phone
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
if showingLeads, let person = selectedPerson {
|
||||||
|
let lead = leads[indexPath.row]
|
||||||
|
onLeadSelected?(OrderSourceSelection(person: person, lead: lead))
|
||||||
|
dismiss(animated: true)
|
||||||
|
} else {
|
||||||
|
onPersonSelected?(people[indexPath.row])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
suixinkan/UI/Orders/OrderStatusBadgeView.swift
Normal file
47
suixinkan/UI/Orders/OrderStatusBadgeView.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// OrderStatusBadgeView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 订单状态标签。
|
||||||
|
final class OrderStatusBadgeView: UIView {
|
||||||
|
private let label = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
label.textAlignment = .center
|
||||||
|
addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4))
|
||||||
|
}
|
||||||
|
layer.cornerRadius = 4
|
||||||
|
clipsToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(status: Int, text: String) {
|
||||||
|
label.text = text
|
||||||
|
switch status {
|
||||||
|
case 18, 20:
|
||||||
|
label.textColor = UIColor(hex: 0x0073FF)
|
||||||
|
backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||||
|
case 30:
|
||||||
|
label.textColor = UIColor(hex: 0x10B981)
|
||||||
|
backgroundColor = UIColor(hex: 0xF0FDF4)
|
||||||
|
case 50:
|
||||||
|
label.textColor = UIColor(hex: 0xEF4444)
|
||||||
|
backgroundColor = UIColor(hex: 0xFFE7E7)
|
||||||
|
default:
|
||||||
|
label.textColor = UIColor(hex: 0xFF7B00)
|
||||||
|
backgroundColor = UIColor(hex: 0xFFF0E2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,26 +3,476 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 订单 Tab 根页面。
|
/// 订单 Tab 根页面,按角色分流摄影师/景区管理员/店铺管理员。
|
||||||
final class OrdersViewController: BaseViewController {
|
final class OrdersViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
|
||||||
private let titleLabel = UILabel()
|
private let orderAPI = NetworkServices.shared.orderAPI
|
||||||
|
private let orderListViewModel = OrderListViewModel()
|
||||||
|
private let depositViewModel = DepositOrderListViewModel()
|
||||||
|
|
||||||
|
private let filterBar = OrderFilterBarView()
|
||||||
|
private let listView = OrderListView()
|
||||||
|
|
||||||
|
private var hasInitialized = false
|
||||||
|
private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin }
|
||||||
|
private var pendingOrderSourceNumber: String?
|
||||||
|
private var pendingOrderSourcePerson: OrderSourcePerson?
|
||||||
|
private var pendingGiftOrderNumber: String?
|
||||||
|
private var isFetchingMultiVerifySpots = false
|
||||||
|
private let locationProvider = OrderLocationProvider()
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "订单"
|
||||||
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
title = "订单"
|
view.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||||
titleLabel.text = "订单"
|
view.addSubview(filterBar)
|
||||||
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
|
view.addSubview(listView)
|
||||||
titleLabel.textAlignment = .center
|
listView.tableView.dataSource = self
|
||||||
titleLabel.textColor = AppColor.text333
|
listView.tableView.delegate = self
|
||||||
view.addSubview(titleLabel)
|
listView.tableView.register(OrderCardCell.self, forCellReuseIdentifier: OrderCardCell.reuseIdentifier)
|
||||||
|
listView.tableView.register(DepositOrderCardCell.self, forCellReuseIdentifier: DepositOrderCardCell.reuseIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
titleLabel.snp.makeConstraints { make in
|
filterBar.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
listView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(filterBar.snp.bottom)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
orderListViewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
orderListViewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
depositViewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
depositViewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
filterBar.onSearchChanged = { [weak self] text in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.isStoreMode {
|
||||||
|
self.depositViewModel.onSearchTextChange(text, api: self.orderAPI)
|
||||||
|
} else {
|
||||||
|
self.orderListViewModel.onSearchTextChange(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterBar.onSearchSubmitted = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
if self.isStoreMode {
|
||||||
|
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
} else {
|
||||||
|
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterBar.onFilterSelected = { [weak self] tab, rTab in
|
||||||
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
if self.isStoreMode {
|
||||||
|
self.depositViewModel.onFilterChange(tab: tab)
|
||||||
|
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
} else {
|
||||||
|
self.orderListViewModel.onFilterChange(orderStatus: tab, isRefined: rTab)
|
||||||
|
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filterBar.onDateRangeTap = { [weak self] in
|
||||||
|
self?.presentDateRangeSheet()
|
||||||
|
}
|
||||||
|
listView.onRefresh = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
if self.isStoreMode {
|
||||||
|
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
} else {
|
||||||
|
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listView.onLoadMore = { [weak self] index in
|
||||||
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
if self.isStoreMode {
|
||||||
|
guard self.depositViewModel.shouldLoadMore(at: index) else { return }
|
||||||
|
await self.depositViewModel.loadMore(api: self.orderAPI)
|
||||||
|
} else {
|
||||||
|
guard self.orderListViewModel.shouldLoadMore(at: index) else { return }
|
||||||
|
await self.orderListViewModel.loadMore(api: self.orderAPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
guard !hasInitialized else { return }
|
||||||
|
hasInitialized = true
|
||||||
|
Task { await initializeList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializeList() async {
|
||||||
|
if isStoreMode {
|
||||||
|
await depositViewModel.refreshOrderList(api: orderAPI)
|
||||||
|
} else {
|
||||||
|
await orderListViewModel.refreshOrderList(api: orderAPI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyViewModel() {
|
||||||
|
if isStoreMode {
|
||||||
|
filterBar.configure(
|
||||||
|
isStoreMode: true,
|
||||||
|
filterLabel: depositViewModel.filterLabel,
|
||||||
|
dateRangeText: dateRangeText(start: depositViewModel.startTime, end: depositViewModel.endTime),
|
||||||
|
searchText: depositViewModel.searchText
|
||||||
|
)
|
||||||
|
listView.apply(
|
||||||
|
isRefreshing: depositViewModel.isRefreshing,
|
||||||
|
canLoadMore: depositViewModel.canLoadMore,
|
||||||
|
isEmpty: depositViewModel.orderList.isEmpty
|
||||||
|
)
|
||||||
|
if let response = depositViewModel.writeOffResponse {
|
||||||
|
depositViewModel.dismissWriteOffDialog()
|
||||||
|
WriteOffResultAlertController.present(from: self, response: response)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filterBar.configure(
|
||||||
|
isStoreMode: false,
|
||||||
|
filterLabel: orderListViewModel.filterLabel,
|
||||||
|
dateRangeText: dateRangeText(start: orderListViewModel.startTime, end: orderListViewModel.endTime),
|
||||||
|
searchText: orderListViewModel.searchText
|
||||||
|
)
|
||||||
|
listView.apply(
|
||||||
|
isRefreshing: orderListViewModel.isRefreshing,
|
||||||
|
canLoadMore: orderListViewModel.canLoadMore,
|
||||||
|
isEmpty: orderListViewModel.orderList.isEmpty
|
||||||
|
)
|
||||||
|
if let response = orderListViewModel.signInResponse {
|
||||||
|
orderListViewModel.clearSignInResponse()
|
||||||
|
OrderSignInAlertController.present(from: self, response: response)
|
||||||
|
}
|
||||||
|
if let response = orderListViewModel.writeOffResponse {
|
||||||
|
orderListViewModel.clearWriteOffResponse()
|
||||||
|
WriteOffResultAlertController.present(from: self, response: response)
|
||||||
|
}
|
||||||
|
if orderListViewModel.multiVerifyPendingData != nil,
|
||||||
|
orderListViewModel.multiVerifyScenicSpotList.isEmpty,
|
||||||
|
!isFetchingMultiVerifySpots {
|
||||||
|
Task { await self.fetchMultiVerifySpotsIfNeeded() }
|
||||||
|
}
|
||||||
|
if !orderListViewModel.multiVerifyScenicSpotList.isEmpty {
|
||||||
|
presentMultiVerifySheet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listView.tableView.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dateRangeText(start: Date?, end: Date?) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy/MM/dd"
|
||||||
|
if let start, let end {
|
||||||
|
return "\(formatter.string(from: start))-\(formatter.string(from: end))"
|
||||||
|
}
|
||||||
|
return "全部"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentDateRangeSheet() {
|
||||||
|
let start = isStoreMode ? depositViewModel.startTime : orderListViewModel.startTime
|
||||||
|
let end = isStoreMode ? depositViewModel.endTime : orderListViewModel.endTime
|
||||||
|
let controller = DateRangePickerViewController(rangeStart: start, rangeEnd: end)
|
||||||
|
controller.onShortcutSelected = { [weak self, weak controller] shortcut in
|
||||||
|
guard let self else { return }
|
||||||
|
let range = self.resolveShortcutRange(shortcut)
|
||||||
|
Task {
|
||||||
|
if self.isStoreMode {
|
||||||
|
self.depositViewModel.onDateRangeSelected(start: range.start, end: range.end)
|
||||||
|
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
} else {
|
||||||
|
self.orderListViewModel.onDateRangeSelected(start: range.start, end: range.end)
|
||||||
|
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
}
|
||||||
|
controller?.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.onRangeSelected = { [weak self, weak controller] start, end in
|
||||||
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
if self.isStoreMode {
|
||||||
|
self.depositViewModel.onDateRangeSelected(start: start, end: end)
|
||||||
|
await self.depositViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
} else {
|
||||||
|
self.orderListViewModel.onDateRangeSelected(start: start, end: end)
|
||||||
|
await self.orderListViewModel.refreshOrderList(api: self.orderAPI)
|
||||||
|
}
|
||||||
|
controller?.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let sheet = controller.sheetPresentationController {
|
||||||
|
sheet.detents = [.medium(), .large()]
|
||||||
|
}
|
||||||
|
present(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveShortcutRange(_ shortcut: String) -> (start: Date?, end: Date?) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
switch shortcut {
|
||||||
|
case StatisticsRangeShortcut.today.rawValue:
|
||||||
|
return (today, today)
|
||||||
|
case StatisticsRangeShortcut.yesterday.rawValue:
|
||||||
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
|
||||||
|
return (yesterday, yesterday)
|
||||||
|
case StatisticsRangeShortcut.last7Days.rawValue:
|
||||||
|
let start = calendar.date(byAdding: .day, value: -6, to: today)!
|
||||||
|
return (start, today)
|
||||||
|
default:
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentScanner(onResult: @escaping (String) -> Void) {
|
||||||
|
let scanner = QRCodeScannerViewController()
|
||||||
|
scanner.onScanResult = onResult
|
||||||
|
let nav = UINavigationController(rootViewController: scanner)
|
||||||
|
nav.modalPresentationStyle = .fullScreen
|
||||||
|
present(nav, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchMultiVerifySpotsIfNeeded() async {
|
||||||
|
isFetchingMultiVerifySpots = true
|
||||||
|
defer { isFetchingMultiVerifySpots = false }
|
||||||
|
do {
|
||||||
|
let coordinate = try await locationProvider.requestCoordinate()
|
||||||
|
await orderListViewModel.fetchScenicSpotsAndShowSheet(
|
||||||
|
lat: coordinate.latitude,
|
||||||
|
lng: coordinate.longitude,
|
||||||
|
api: orderAPI
|
||||||
|
)
|
||||||
|
applyViewModel()
|
||||||
|
} catch {
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
orderListViewModel.clearMultiVerifyPending()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentMultiVerifySheet() {
|
||||||
|
let spots = orderListViewModel.multiVerifyScenicSpotList
|
||||||
|
guard !spots.isEmpty else { return }
|
||||||
|
let controller = MultiVerifyScenicSpotViewController(
|
||||||
|
spots: spots,
|
||||||
|
selected: orderListViewModel.selectedMultiVerifySpot
|
||||||
|
)
|
||||||
|
controller.onConfirm = { [weak self] spot in
|
||||||
|
guard let self else { return }
|
||||||
|
self.orderListViewModel.onMultiVerifySpotSelected(spot)
|
||||||
|
Task { await self.orderListViewModel.onMultiVerifyConfirm(api: self.orderAPI) }
|
||||||
|
}
|
||||||
|
controller.onCancel = { [weak self] in
|
||||||
|
self?.orderListViewModel.clearMultiVerifySheet()
|
||||||
|
}
|
||||||
|
let nav = UINavigationController(rootViewController: controller)
|
||||||
|
if let sheet = nav.sheetPresentationController {
|
||||||
|
sheet.detents = [.medium(), .large()]
|
||||||
|
}
|
||||||
|
present(nav, animated: true)
|
||||||
|
orderListViewModel.clearMultiVerifySheet()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableView
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
isStoreMode ? depositViewModel.orderList.count : orderListViewModel.orderList.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
if isStoreMode {
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCardCell.reuseIdentifier, for: indexPath) as! DepositOrderCardCell
|
||||||
|
let item = depositViewModel.orderList[indexPath.row]
|
||||||
|
cell.configure(item: item, actions: makeDepositActions(for: item))
|
||||||
|
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: depositViewModel.shouldLoadMore(at: indexPath.row))
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: OrderCardCell.reuseIdentifier, for: indexPath) as! OrderCardCell
|
||||||
|
let order = orderListViewModel.orderList[indexPath.row]
|
||||||
|
let source = orderListViewModel.orderSourceSelection(for: order)
|
||||||
|
cell.configure(
|
||||||
|
order: order,
|
||||||
|
hideWriteActions: orderListViewModel.isScenicAdminReadOnly,
|
||||||
|
actions: makeOrderActions(for: order),
|
||||||
|
showOrderSource: orderListViewModel.shouldShowOrderSource(for: order),
|
||||||
|
sourceText: source.map { "\($0.person.name) · \($0.lead.displayText)" }
|
||||||
|
)
|
||||||
|
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: orderListViewModel.shouldLoadMore(at: indexPath.row))
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeOrderActions(for order: OrderEntity) -> OrderCardActions {
|
||||||
|
var actions = OrderCardActions()
|
||||||
|
actions.onRefund = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
OrderRefundAlertController.present(from: self, order: order) { request in
|
||||||
|
Task { await self.orderListViewModel.refundOrder(request, api: self.orderAPI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions.onVerify = { [weak self] in
|
||||||
|
self?.presentScanner { result in
|
||||||
|
Task { await self?.orderListViewModel.processScanResult(result, api: self?.orderAPI ?? NetworkServices.shared.orderAPI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions.onUploadTask = { [weak self] in
|
||||||
|
self?.showToast("上传任务待接入")
|
||||||
|
}
|
||||||
|
actions.onCancel = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.orderListViewModel.cancelOrder(orderNumber: order.orderNumber, api: self.orderAPI) }
|
||||||
|
}
|
||||||
|
actions.onCallPhone = {
|
||||||
|
guard let url = URL(string: "tel://\(order.phone)") else { return }
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
actions.onRefinedChanged = { [weak self] value in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.orderListViewModel.updateRefined(orderNumber: order.orderNumber, isRefined: value, api: self.orderAPI) }
|
||||||
|
}
|
||||||
|
actions.onHistoricalPhoto = { [weak self] in
|
||||||
|
let controller = HistoricalShootingInfoViewController(orderNumber: order.orderNumber)
|
||||||
|
self?.navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
actions.onOrderSourceTap = { [weak self] in
|
||||||
|
self?.presentOrderSourcePicker(for: order)
|
||||||
|
}
|
||||||
|
if let travel = order.photoTravel, travel.canGiftRetouch {
|
||||||
|
actions.onSendGift = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.pendingGiftOrderNumber = order.orderNumber
|
||||||
|
SendGiftAlertController.present(
|
||||||
|
from: self,
|
||||||
|
photoNum: travel.retouchGiftPhotoNum,
|
||||||
|
videoNum: travel.retouchGiftVideoNum
|
||||||
|
) { photo, video in
|
||||||
|
Task {
|
||||||
|
await self.orderListViewModel.sendGift(
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
photoNum: photo,
|
||||||
|
videoNum: video,
|
||||||
|
api: self.orderAPI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDepositActions(for item: StoreOrderItem) -> DepositOrderCardActions {
|
||||||
|
var actions = DepositOrderCardActions()
|
||||||
|
actions.onRefund = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
DepositOrderRefundAlertController.present(
|
||||||
|
from: self,
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
amount: item.actualPayAmount,
|
||||||
|
refundType: 1
|
||||||
|
) { amount, reason in
|
||||||
|
Task {
|
||||||
|
await self.depositViewModel.requestRefund(
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
refundType: 1,
|
||||||
|
refundAmount: amount,
|
||||||
|
refundReason: reason,
|
||||||
|
api: self.orderAPI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions.onPartialRefund = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
DepositOrderRefundAlertController.present(
|
||||||
|
from: self,
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
amount: item.actualPayAmount,
|
||||||
|
refundType: 3
|
||||||
|
) { amount, reason in
|
||||||
|
Task {
|
||||||
|
await self.depositViewModel.requestRefund(
|
||||||
|
orderNumber: item.orderNumber,
|
||||||
|
refundType: 3,
|
||||||
|
refundAmount: amount,
|
||||||
|
refundReason: reason,
|
||||||
|
api: self.orderAPI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions.onVerify = { [weak self] in
|
||||||
|
self?.presentScanner { result in
|
||||||
|
Task { await self?.depositViewModel.processScanResult(result, api: self?.orderAPI ?? NetworkServices.shared.orderAPI) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions.onTap = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.depositViewModel.canOpenDetail(for: item) else {
|
||||||
|
self.showToast("该订单类型暂无详情")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let controller = DepositOrderDetailViewController(orderNumber: item.orderNumber)
|
||||||
|
self.navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentOrderSourcePicker(for order: OrderEntity) {
|
||||||
|
pendingOrderSourceNumber = order.orderNumber
|
||||||
|
Task {
|
||||||
|
await orderListViewModel.onOrderSourceSheetOpen(api: orderAPI)
|
||||||
|
let controller = OrderSourcePickerViewController(
|
||||||
|
people: orderListViewModel.orderSourcePeople,
|
||||||
|
leads: [],
|
||||||
|
selectedPerson: nil
|
||||||
|
)
|
||||||
|
controller.onPersonSelected = { [weak self] person in
|
||||||
|
guard let self else { return }
|
||||||
|
self.pendingOrderSourcePerson = person
|
||||||
|
Task {
|
||||||
|
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
|
||||||
|
let leadsController = OrderSourcePickerViewController(
|
||||||
|
people: self.orderListViewModel.orderSourcePeople,
|
||||||
|
leads: self.orderListViewModel.orderSourceLeads[person.key] ?? [],
|
||||||
|
selectedPerson: person
|
||||||
|
)
|
||||||
|
leadsController.onLeadSelected = { [weak self] selection in
|
||||||
|
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
|
||||||
|
Task {
|
||||||
|
await self.orderListViewModel.bindOrderSource(
|
||||||
|
orderNumber: orderNumber,
|
||||||
|
selection: selection,
|
||||||
|
api: self.orderAPI
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.present(leadsController, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
150
suixinkan/UI/Scan/QRCodeScannerViewController.swift
Normal file
150
suixinkan/UI/Scan/QRCodeScannerViewController.swift
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
//
|
||||||
|
// QRCodeScannerViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// AVFoundation 二维码扫描页。
|
||||||
|
final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
|
||||||
|
var onScanResult: ((String) -> Void)?
|
||||||
|
|
||||||
|
private let captureSession = AVCaptureSession()
|
||||||
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
|
private var hasHandledResult = false
|
||||||
|
|
||||||
|
private let maskView = UIView()
|
||||||
|
private let hintLabel = UILabel()
|
||||||
|
private let closeButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
navigationController?.setNavigationBarHidden(true, animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = .black
|
||||||
|
hintLabel.text = "将二维码放入框内"
|
||||||
|
hintLabel.textColor = .white
|
||||||
|
hintLabel.font = .systemFont(ofSize: 14)
|
||||||
|
hintLabel.textAlignment = .center
|
||||||
|
|
||||||
|
closeButton.setTitle("关闭", for: .normal)
|
||||||
|
closeButton.setTitleColor(.white, for: .normal)
|
||||||
|
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
view.addSubview(maskView)
|
||||||
|
view.addSubview(hintLabel)
|
||||||
|
view.addSubview(closeButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
closeButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
hintLabel.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(48)
|
||||||
|
}
|
||||||
|
maskView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(260)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
checkCameraPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
previewLayer?.frame = view.bounds
|
||||||
|
updateMask()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
if captureSession.isRunning {
|
||||||
|
captureSession.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkCameraPermission() {
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .authorized:
|
||||||
|
setupCamera()
|
||||||
|
case .notDetermined:
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
granted ? self?.setupCamera() : self?.showPermissionDenied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
showPermissionDenied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPermissionDenied() {
|
||||||
|
showToast("需要相机权限才能扫码")
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupCamera() {
|
||||||
|
guard let device = AVCaptureDevice.default(for: .video),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device) else {
|
||||||
|
showToast("无法打开相机")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard captureSession.canAddInput(input) else { return }
|
||||||
|
captureSession.addInput(input)
|
||||||
|
|
||||||
|
let output = AVCaptureMetadataOutput()
|
||||||
|
guard captureSession.canAddOutput(output) else { return }
|
||||||
|
captureSession.addOutput(output)
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||||
|
output.metadataObjectTypes = [.qr]
|
||||||
|
|
||||||
|
let layer = AVCaptureVideoPreviewLayer(session: captureSession)
|
||||||
|
layer.videoGravity = .resizeAspectFill
|
||||||
|
view.layer.insertSublayer(layer, at: 0)
|
||||||
|
previewLayer = layer
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
self?.captureSession.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMask() {
|
||||||
|
maskView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
|
||||||
|
let border = CAShapeLayer()
|
||||||
|
border.path = UIBezierPath(roundedRect: maskView.bounds, cornerRadius: 12).cgPath
|
||||||
|
border.strokeColor = AppColor.primary.cgColor
|
||||||
|
border.fillColor = UIColor.clear.cgColor
|
||||||
|
border.lineWidth = 2
|
||||||
|
maskView.layer.addSublayer(border)
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataOutput(
|
||||||
|
_ output: AVCaptureMetadataOutput,
|
||||||
|
didOutput metadataObjects: [AVMetadataObject],
|
||||||
|
from connection: AVCaptureConnection
|
||||||
|
) {
|
||||||
|
guard !hasHandledResult,
|
||||||
|
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||||
|
object.type == .qr,
|
||||||
|
let value = object.stringValue,
|
||||||
|
!value.isEmpty else { return }
|
||||||
|
hasHandledResult = true
|
||||||
|
captureSession.stopRunning()
|
||||||
|
onScanResult?(value)
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closeTapped() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
suixinkanTests/DepositOrderListViewModelTests.swift
Normal file
64
suixinkanTests/DepositOrderListViewModelTests.swift
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// DepositOrderListViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DepositOrderListViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
AppStore.shared.currentStoreId = 0
|
||||||
|
AppStore.shared.currentScenicId = 0
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStoreFilterMapsPaidStatusToAPI20() {
|
||||||
|
XCTAssertEqual(DepositOrderListViewModel.apiOrderStatus(forTabIndex: 18), 20)
|
||||||
|
XCTAssertEqual(DepositOrderListViewModel.apiOrderStatus(forTabIndex: 30), 30)
|
||||||
|
XCTAssertNil(DepositOrderListViewModel.apiOrderStatus(forTabIndex: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStoreFilterLabelUsesPaidText() {
|
||||||
|
let viewModel = DepositOrderListViewModel()
|
||||||
|
viewModel.onFilterChange(tab: 18)
|
||||||
|
XCTAssertEqual(viewModel.filterLabel, "已支付")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPhoneValidation() {
|
||||||
|
XCTAssertTrue(DepositOrderListViewModel.isValidPhone("13800138000"))
|
||||||
|
XCTAssertFalse(DepositOrderListViewModel.isValidPhone("12345"))
|
||||||
|
XCTAssertFalse(DepositOrderListViewModel.isValidPhone("23800138000"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStoreIdZeroAbortsWithMessage() async {
|
||||||
|
AppStore.shared.currentStoreId = 0
|
||||||
|
let viewModel = DepositOrderListViewModel()
|
||||||
|
var message: String?
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
let session = MockURLSession(responses: [])
|
||||||
|
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
await viewModel.refreshOrderList(api: api)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.orderList.isEmpty)
|
||||||
|
XCTAssertEqual(message, "店铺信息异常,请先选择店铺")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCanOpenDetailOnlyForType19() throws {
|
||||||
|
let viewModel = DepositOrderListViewModel()
|
||||||
|
let json = """
|
||||||
|
{"order_number":"A","order_type":19,"order_status":20}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let item = try JSONDecoder().decode(StoreOrderItem.self, from: json)
|
||||||
|
XCTAssertTrue(viewModel.canOpenDetail(for: item))
|
||||||
|
|
||||||
|
let otherJSON = """
|
||||||
|
{"order_number":"B","order_type":4,"order_status":20}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let other = try JSONDecoder().decode(StoreOrderItem.self, from: otherJSON)
|
||||||
|
XCTAssertFalse(viewModel.canOpenDetail(for: other))
|
||||||
|
}
|
||||||
|
}
|
||||||
85
suixinkanTests/OrderListViewModelTests.swift
Normal file
85
suixinkanTests/OrderListViewModelTests.swift
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
//
|
||||||
|
// OrderListViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class OrderListViewModelTests: XCTestCase {
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
AppStore.shared.roleCode = ""
|
||||||
|
AppStore.shared.roleName = ""
|
||||||
|
AppStore.shared.currentScenicId = 0
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOrderStatusQueryMapping() {
|
||||||
|
XCTAssertNil(OrderListQueryBuilder.orderStatusQuery(tabIndex: 0))
|
||||||
|
XCTAssertEqual(OrderListQueryBuilder.orderStatusQuery(tabIndex: 18), "18")
|
||||||
|
XCTAssertEqual(OrderListQueryBuilder.orderStatusQuery(tabIndex: 30), "30")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIsRefinedQueryMapping() {
|
||||||
|
XCTAssertNil(OrderListQueryBuilder.isRefinedQuery(rTabIndex: 0))
|
||||||
|
XCTAssertEqual(OrderListQueryBuilder.isRefinedQuery(rTabIndex: 1), 1)
|
||||||
|
XCTAssertEqual(OrderListQueryBuilder.isRefinedQuery(rTabIndex: 2), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPhotographerFilterLabels() {
|
||||||
|
let viewModel = OrderListViewModel()
|
||||||
|
viewModel.onFilterChange(orderStatus: 18, isRefined: 0)
|
||||||
|
XCTAssertEqual(viewModel.filterLabel, "已付定金")
|
||||||
|
viewModel.onFilterChange(orderStatus: 0, isRefined: 1)
|
||||||
|
XCTAssertEqual(viewModel.filterLabel, "需要精修")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRoleRoutingPath() {
|
||||||
|
AppStore.shared.roleCode = AppRoleCode.scenicAdmin.rawValue
|
||||||
|
let scenicVM = OrderListViewModel()
|
||||||
|
XCTAssertEqual(scenicVM.listAPIPath, "/api/app/scenic-admin/order/list")
|
||||||
|
XCTAssertTrue(scenicVM.isScenicAdminReadOnly)
|
||||||
|
|
||||||
|
AppStore.shared.roleCode = AppRoleCode.photographer.rawValue
|
||||||
|
let photoVM = OrderListViewModel()
|
||||||
|
XCTAssertEqual(photoVM.listAPIPath, "/api/yf-handset-app/photog/order/listv2")
|
||||||
|
XCTAssertFalse(photoVM.isScenicAdminReadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadMoreBoundary() {
|
||||||
|
let viewModel = OrderListViewModel()
|
||||||
|
XCTAssertFalse(viewModel.shouldLoadMore(at: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOrderEntityDecodesPhotoTravel() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"total": 1,
|
||||||
|
"list": [{
|
||||||
|
"order_number": "NO001",
|
||||||
|
"order_status": 18,
|
||||||
|
"order_status_name": "已付定金",
|
||||||
|
"actual_pay_amount": "99.00",
|
||||||
|
"photo_travel": {
|
||||||
|
"order_photo_num": 10,
|
||||||
|
"order_video_num": 1,
|
||||||
|
"need_check_in": true,
|
||||||
|
"can_cancel_order": true
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let envelope = try JSONDecoder().decode(APIEnvelope<OrderListResponse<OrderEntity>>.self, from: json)
|
||||||
|
let order = try XCTUnwrap(envelope.data?.list.first)
|
||||||
|
XCTAssertEqual(order.orderNumber, "NO001")
|
||||||
|
XCTAssertEqual(order.photoTravel?.orderPhotoNum, 10)
|
||||||
|
XCTAssertEqual(order.photoTravel?.needCheckIn, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user