From 31d2b6094be8aeacc8754a26adac1c2e666f67ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 6 Jul 2026 16:59:53 +0800 Subject: [PATCH] Implement orders tab aligned with Android, including scan verify flows. Add role-based order/deposit lists, AVFoundation QR scanning, verify dialogs, and ViewModel tests so photographers, scenic admins, and store admins match Android behavior. Co-authored-by: Cursor --- suixinkan/App/NetworkServices.swift | 3 + suixinkan/Core/Networking/APIClient.swift | 56 ++ suixinkan/Features/Orders/API/OrderAPI.swift | 243 +++++ .../Features/Orders/Models/OrderModels.swift | 876 ++++++++++++++++++ .../Orders/Models/QRCodeScanResult.swift | 73 ++ .../Orders/Utils/MainTabScanHandler.swift | 203 ++++ .../Orders/Utils/OrderLocationProvider.swift | 55 ++ .../Orders/Utils/SaleUserQrParser.swift | 55 ++ .../DepositOrderListViewModel.swift | 243 +++++ .../ViewModels/OrderListViewModel.swift | 473 ++++++++++ suixinkan/Info.plist | 4 + .../DateRangePickerViewController.swift} | 13 +- .../UI/MainTab/MainTabBarController.swift | 45 +- .../UI/Orders/DepositOrderCardCell.swift | 144 +++ .../DepositOrderDetailViewController.swift | 88 ++ ...HistoricalShootingInfoViewController.swift | 68 ++ .../MultiVerifyScenicSpotViewController.swift | 91 ++ suixinkan/UI/Orders/OrderCardCell.swift | 314 +++++++ .../UI/Orders/OrderDialogControllers.swift | 95 ++ suixinkan/UI/Orders/OrderFilterBarView.swift | 134 +++ suixinkan/UI/Orders/OrderListView.swift | 73 ++ .../OrderSourcePickerViewController.swift | 76 ++ .../UI/Orders/OrderStatusBadgeView.swift | 47 + .../UI/Orders/OrdersViewController.swift | 472 +++++++++- .../UI/Scan/QRCodeScannerViewController.swift | 150 +++ .../DepositOrderListViewModelTests.swift | 64 ++ suixinkanTests/OrderListViewModelTests.swift | 85 ++ 27 files changed, 4226 insertions(+), 17 deletions(-) create mode 100644 suixinkan/Features/Orders/API/OrderAPI.swift create mode 100644 suixinkan/Features/Orders/Models/OrderModels.swift create mode 100644 suixinkan/Features/Orders/Models/QRCodeScanResult.swift create mode 100644 suixinkan/Features/Orders/Utils/MainTabScanHandler.swift create mode 100644 suixinkan/Features/Orders/Utils/OrderLocationProvider.swift create mode 100644 suixinkan/Features/Orders/Utils/SaleUserQrParser.swift create mode 100644 suixinkan/Features/Orders/ViewModels/DepositOrderListViewModel.swift create mode 100644 suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift rename suixinkan/UI/{Statistics/StatisticsDateRangeViewController.swift => Common/DateRangePickerViewController.swift} (95%) create mode 100644 suixinkan/UI/Orders/DepositOrderCardCell.swift create mode 100644 suixinkan/UI/Orders/DepositOrderDetailViewController.swift create mode 100644 suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift create mode 100644 suixinkan/UI/Orders/MultiVerifyScenicSpotViewController.swift create mode 100644 suixinkan/UI/Orders/OrderCardCell.swift create mode 100644 suixinkan/UI/Orders/OrderDialogControllers.swift create mode 100644 suixinkan/UI/Orders/OrderFilterBarView.swift create mode 100644 suixinkan/UI/Orders/OrderListView.swift create mode 100644 suixinkan/UI/Orders/OrderSourcePickerViewController.swift create mode 100644 suixinkan/UI/Orders/OrderStatusBadgeView.swift create mode 100644 suixinkan/UI/Scan/QRCodeScannerViewController.swift create mode 100644 suixinkanTests/DepositOrderListViewModelTests.swift create mode 100644 suixinkanTests/OrderListViewModelTests.swift diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 468e269..2d642eb 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -14,6 +14,7 @@ final class NetworkServices { let authAPI: AuthAPI let profileAPI: ProfileAPI let statisticsAPI: StatisticsAPI + let orderAPI: OrderAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -23,6 +24,7 @@ final class NetworkServices { authAPI = AuthAPI(client: client) profileAPI = ProfileAPI(client: client) statisticsAPI = StatisticsAPI(client: client) + orderAPI = OrderAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -37,6 +39,7 @@ final class NetworkServices { authAPI = AuthAPI(client: apiClient) profileAPI = ProfileAPI(client: apiClient) statisticsAPI = StatisticsAPI(client: apiClient) + orderAPI = OrderAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/Core/Networking/APIClient.swift b/suixinkan/Core/Networking/APIClient.swift index d45088f..299200a 100644 --- a/suixinkan/Core/Networking/APIClient.swift +++ b/suixinkan/Core/Networking/APIClient.swift @@ -90,6 +90,62 @@ final class APIClient { } } + /// 发送原始 JSON 字符串请求体(对齐 Android `TextContent` 裸 JSON POST)。 + func sendRawJSON( + 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 和请求体。 private func makeURLRequest( _ apiRequest: APIRequest, diff --git a/suixinkan/Features/Orders/API/OrderAPI.swift b/suixinkan/Features/Orders/API/OrderAPI.swift new file mode 100644 index 0000000..393435d --- /dev/null +++ b/suixinkan/Features/Orders/API/OrderAPI.swift @@ -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 { + 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 { + 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), + ] + ) + ) + } +} diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift new file mode 100644 index 0000000..389ef45 --- /dev/null +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -0,0 +1,876 @@ +// +// OrderModels.swift +// suixinkan +// + +import Foundation + +// MARK: - List Response + +/// 订单列表分页响应,字段为 `list`(非 `data`)。 +struct OrderListResponse: 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, + 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 + } +} diff --git a/suixinkan/Features/Orders/Models/QRCodeScanResult.swift b/suixinkan/Features/Orders/Models/QRCodeScanResult.swift new file mode 100644 index 0000000..6c23231 --- /dev/null +++ b/suixinkan/Features/Orders/Models/QRCodeScanResult.swift @@ -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) + } +} diff --git a/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift b/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift new file mode 100644 index 0000000..3f03ab1 --- /dev/null +++ b/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift @@ -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) -> 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) + } +} diff --git a/suixinkan/Features/Orders/Utils/OrderLocationProvider.swift b/suixinkan/Features/Orders/Utils/OrderLocationProvider.swift new file mode 100644 index 0000000..fca24f0 --- /dev/null +++ b/suixinkan/Features/Orders/Utils/OrderLocationProvider.swift @@ -0,0 +1,55 @@ +// +// OrderLocationProvider.swift +// suixinkan +// + +import CoreLocation + +/// 一次性定位,用于多点位核销拉取打卡点。 +final class OrderLocationProvider: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + private var continuation: ((Result) -> 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: "缺少定位权限,功能无法进行" + } + } +} diff --git a/suixinkan/Features/Orders/Utils/SaleUserQrParser.swift b/suixinkan/Features/Orders/Utils/SaleUserQrParser.swift new file mode 100644 index 0000000..672cfff --- /dev/null +++ b/suixinkan/Features/Orders/Utils/SaleUserQrParser.swift @@ -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 + } +} diff --git a/suixinkan/Features/Orders/ViewModels/DepositOrderListViewModel.swift b/suixinkan/Features/Orders/ViewModels/DepositOrderListViewModel.swift new file mode 100644 index 0000000..e866c37 --- /dev/null +++ b/suixinkan/Features/Orders/ViewModels/DepositOrderListViewModel.swift @@ -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? + + 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?() + } +} diff --git a/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift new file mode 100644 index 0000000..e3bcf94 --- /dev/null +++ b/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift @@ -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 + 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?() + } +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index d76da8f..b685371 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -2,6 +2,10 @@ + NSCameraUsageDescription + 需要访问相机以扫描订单核销二维码 + NSLocationWhenInUseUsageDescription + 需要定位以获取多点位核销打卡点列表 NSPhotoLibraryUsageDescription 需要访问相册以选择头像、身份证和银行卡照片 UIApplicationSceneManifest diff --git a/suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift b/suixinkan/UI/Common/DateRangePickerViewController.swift similarity index 95% rename from suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift rename to suixinkan/UI/Common/DateRangePickerViewController.swift index 4441db3..cfcf099 100644 --- a/suixinkan/UI/Statistics/StatisticsDateRangeViewController.swift +++ b/suixinkan/UI/Common/DateRangePickerViewController.swift @@ -1,13 +1,13 @@ // -// StatisticsDateRangeViewController.swift +// DateRangePickerViewController.swift // suixinkan // import SnapKit import UIKit -/// 数据统计日期范围选择 Sheet,对齐 Android `DateRangeBottomSheet`。 -final class StatisticsDateRangeViewController: UIViewController { +/// 通用日期范围选择 Sheet,从 Statistics 抽取,对齐 Android `DateRangeBottomSheet`。 +final class DateRangePickerViewController: UIViewController { var onShortcutSelected: ((String) -> Void)? var onRangeSelected: ((Date, Date) -> Void)? @@ -227,7 +227,7 @@ final class StatisticsDateRangeViewController: UIViewController { } private final class DateCell: UICollectionViewCell { - static let reuseIdentifier = "StatisticsDateCell" + static let reuseIdentifier = "DateRangePickerDateCell" 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 { monthDates().count } @@ -301,3 +301,6 @@ extension StatisticsDateRangeViewController: UICollectionViewDataSource, UIColle return CGSize(width: width, height: 32) } } + +/// Statistics 模块兼容别名。 +typealias StatisticsDateRangeViewController = DateRangePickerViewController diff --git a/suixinkan/UI/MainTab/MainTabBarController.swift b/suixinkan/UI/MainTab/MainTabBarController.swift index a2309f7..7b184e4 100644 --- a/suixinkan/UI/MainTab/MainTabBarController.swift +++ b/suixinkan/UI/MainTab/MainTabBarController.swift @@ -12,6 +12,8 @@ final class MainTabBarController: UITabBarController { private var tabNavigationControllers: [AppTab: TabNavigationController] = [:] private var isSyncingTabSelection = false + private let scanHandler = MainTabScanHandler(api: NetworkServices.shared.orderAPI) + /// 测试时可注入扫码点击处理。 var onScanTabSelected: (() -> Void)? @@ -47,6 +49,7 @@ final class MainTabBarController: UITabBarController { configureTabBarAppearance() configureTabs() bindBadgeViewModel() + bindScanHandler() badgeViewModel.refreshPendingWriteOffCount() } @@ -118,10 +121,50 @@ final class MainTabBarController: UITabBarController { 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() { 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) navigationController.modalPresentationStyle = .fullScreen present(navigationController, animated: true) diff --git a/suixinkan/UI/Orders/DepositOrderCardCell.swift b/suixinkan/UI/Orders/DepositOrderCardCell.swift new file mode 100644 index 0000000..22c4864 --- /dev/null +++ b/suixinkan/UI/Orders/DepositOrderCardCell.swift @@ -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?() } +} diff --git a/suixinkan/UI/Orders/DepositOrderDetailViewController.swift b/suixinkan/UI/Orders/DepositOrderDetailViewController.swift new file mode 100644 index 0000000..231963d --- /dev/null +++ b/suixinkan/UI/Orders/DepositOrderDetailViewController.swift @@ -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 + } +} diff --git a/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift b/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift new file mode 100644 index 0000000..9dff993 --- /dev/null +++ b/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift @@ -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 + } +} diff --git a/suixinkan/UI/Orders/MultiVerifyScenicSpotViewController.swift b/suixinkan/UI/Orders/MultiVerifyScenicSpotViewController.swift new file mode 100644 index 0000000..f6bc1e2 --- /dev/null +++ b/suixinkan/UI/Orders/MultiVerifyScenicSpotViewController.swift @@ -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() + } +} diff --git a/suixinkan/UI/Orders/OrderCardCell.swift b/suixinkan/UI/Orders/OrderCardCell.swift new file mode 100644 index 0000000..740309d --- /dev/null +++ b/suixinkan/UI/Orders/OrderCardCell.swift @@ -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)" + } +} diff --git a/suixinkan/UI/Orders/OrderDialogControllers.swift b/suixinkan/UI/Orders/OrderDialogControllers.swift new file mode 100644 index 0000000..ae8d37c --- /dev/null +++ b/suixinkan/UI/Orders/OrderDialogControllers.swift @@ -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) + } +} diff --git a/suixinkan/UI/Orders/OrderFilterBarView.swift b/suixinkan/UI/Orders/OrderFilterBarView.swift new file mode 100644 index 0000000..637e301 --- /dev/null +++ b/suixinkan/UI/Orders/OrderFilterBarView.swift @@ -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 + } +} diff --git a/suixinkan/UI/Orders/OrderListView.swift b/suixinkan/UI/Orders/OrderListView.swift new file mode 100644 index 0000000..0be7ef2 --- /dev/null +++ b/suixinkan/UI/Orders/OrderListView.swift @@ -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) + } +} diff --git a/suixinkan/UI/Orders/OrderSourcePickerViewController.swift b/suixinkan/UI/Orders/OrderSourcePickerViewController.swift new file mode 100644 index 0000000..a128683 --- /dev/null +++ b/suixinkan/UI/Orders/OrderSourcePickerViewController.swift @@ -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]) + } + } +} diff --git a/suixinkan/UI/Orders/OrderStatusBadgeView.swift b/suixinkan/UI/Orders/OrderStatusBadgeView.swift new file mode 100644 index 0000000..f35b8d1 --- /dev/null +++ b/suixinkan/UI/Orders/OrderStatusBadgeView.swift @@ -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) + } + } +} diff --git a/suixinkan/UI/Orders/OrdersViewController.swift b/suixinkan/UI/Orders/OrdersViewController.swift index 84e48cc..dae8439 100644 --- a/suixinkan/UI/Orders/OrdersViewController.swift +++ b/suixinkan/UI/Orders/OrdersViewController.swift @@ -3,26 +3,476 @@ // suixinkan // +import CoreLocation import SnapKit import UIKit -/// 订单 Tab 根页面。 -final class OrdersViewController: BaseViewController { +/// 订单 Tab 根页面,按角色分流摄影师/景区管理员/店铺管理员。 +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() { - title = "订单" - titleLabel.text = "订单" - titleLabel.font = .systemFont(ofSize: 24, weight: .medium) - titleLabel.textAlignment = .center - titleLabel.textColor = AppColor.text333 - view.addSubview(titleLabel) + view.backgroundColor = UIColor(hex: 0xF5F5F5) + view.addSubview(filterBar) + view.addSubview(listView) + listView.tableView.dataSource = self + listView.tableView.delegate = self + listView.tableView.register(OrderCardCell.self, forCellReuseIdentifier: OrderCardCell.reuseIdentifier) + listView.tableView.register(DepositOrderCardCell.self, forCellReuseIdentifier: DepositOrderCardCell.reuseIdentifier) } override func setupConstraints() { - titleLabel.snp.makeConstraints { make in - make.center.equalToSuperview() + filterBar.snp.makeConstraints { make in + 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) } } } diff --git a/suixinkan/UI/Scan/QRCodeScannerViewController.swift b/suixinkan/UI/Scan/QRCodeScannerViewController.swift new file mode 100644 index 0000000..fc10291 --- /dev/null +++ b/suixinkan/UI/Scan/QRCodeScannerViewController.swift @@ -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) + } +} diff --git a/suixinkanTests/DepositOrderListViewModelTests.swift b/suixinkanTests/DepositOrderListViewModelTests.swift new file mode 100644 index 0000000..b9613e8 --- /dev/null +++ b/suixinkanTests/DepositOrderListViewModelTests.swift @@ -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)) + } +} \ No newline at end of file diff --git a/suixinkanTests/OrderListViewModelTests.swift b/suixinkanTests/OrderListViewModelTests.swift new file mode 100644 index 0000000..72e7c11 --- /dev/null +++ b/suixinkanTests/OrderListViewModelTests.swift @@ -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>.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) + } +}