diff --git a/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift b/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift new file mode 100644 index 0000000..329b8a4 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift @@ -0,0 +1,248 @@ +// +// CooperationOrderModels.swift +// suixinkan +// + +import Foundation + +/// 合作订单功能常量与校验,对齐 Android `CooperationOrderFeature`。 +enum CooperationOrderFeature { + static let uri = "cooperation_order" + static let remarkNameMaxLength = 10 + + static func remarkNameValidationMessage(_ remark: String) -> String? { + let trimmed = remark.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count > remarkNameMaxLength { + return "备注名不能超过\(remarkNameMaxLength)个字符" + } + return nil + } + + static func hasPermission(in permissions: [HomePermissionItem]) -> Bool { + permissions.contains { $0.uri == uri } + } +} + +/// GET /photog/sale-user/referral-leads 获客线索。 +struct ReferralLeadEntity: Decodable, Equatable { + let id: Int + let leadId: Int + let displayName: String + let photogRemarkName: String + let name: String + let salerName: String + let userPhone: String + let userMobileMasked: String + let phone: String + let remark: String + let createdAt: String + let imageUrls: [String] + let images: [String] + + var displayId: String { String(leadId > 0 ? leadId : id) } + var displayPhone: String { + if !userMobileMasked.isEmpty { return userMobileMasked } + if !userPhone.isEmpty { return userPhone } + return phone + } + + var displayImages: [String] { imageUrls.isEmpty ? images : imageUrls } + + var displaySalerName: String { + if !displayName.isEmpty { return displayName } + return OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) + } + + enum CodingKeys: String, CodingKey { + case id + case leadId = "lead_id" + case displayName = "display_name" + case photogRemarkName = "photog_remark_name" + case name + case salerName = "saler_name" + case userPhone = "user_phone" + case userMobileMasked = "user_mobile_masked" + case phone + case remark + case createdAt = "created_at" + case imageUrls = "image_urls" + case images + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + leadId = try container.decodeIfPresent(Int.self, forKey: .leadId) ?? 0 + displayName = try container.decodeIfPresent(String.self, forKey: .displayName) ?? "" + photogRemarkName = try container.decodeIfPresent(String.self, forKey: .photogRemarkName) ?? "" + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + salerName = try container.decodeIfPresent(String.self, forKey: .salerName) ?? "" + userPhone = try container.decodeIfPresent(String.self, forKey: .userPhone) ?? "" + userMobileMasked = try container.decodeIfPresent(String.self, forKey: .userMobileMasked) ?? "" + phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? "" + remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? "" + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? [] + images = try container.decodeIfPresent([String].self, forKey: .images) ?? [] + } +} + +/// GET /photog/sale-user/acquisition-orders 获客订单。 +struct AcquisitionOrderEntity: Decodable, Equatable { + let id: Int + let orderNumber: String + let projectName: String + let orderType: Int + let orderTypeLabel: String + let orderAmount: String + let amount: String + let actualPayAmount: String + let createdAt: String + let customerPhone: String + let userMobileMasked: String + let phone: String + let saleUserName: String + let photogRemarkName: String + let partnerName: String + + var displayAmount: String { + if !orderAmount.isEmpty { return orderAmount } + if !amount.isEmpty { return amount } + return actualPayAmount + } + + var displayCustomerPhone: String { + if !userMobileMasked.isEmpty { return userMobileMasked } + if !customerPhone.isEmpty { return customerPhone } + return phone + } + + var displayPartner: String { + let salerDisplay = OrderDisplayName.saleUser(remark: photogRemarkName, primary: saleUserName) + if !salerDisplay.isEmpty { return salerDisplay } + return partnerName + } + + enum CodingKeys: String, CodingKey { + case id + case orderNumber = "order_number" + case projectName = "project_name" + case orderType = "order_type" + case orderTypeLabel = "order_type_label" + case orderAmount = "order_amount" + case amount + case actualPayAmount = "actual_pay_amount" + case createdAt = "created_at" + case customerPhone = "customer_phone" + case userMobileMasked = "user_mobile_masked" + case phone + case saleUserName = "sale_user_name" + case photogRemarkName = "photog_remark_name" + case partnerName = "partner_name" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + orderNumber = try container.decodeIfPresent(String.self, forKey: .orderNumber) ?? "" + 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) ?? "" + orderAmount = try container.decodeIfPresent(String.self, forKey: .orderAmount) ?? "" + amount = try container.decodeIfPresent(String.self, forKey: .amount) ?? "" + actualPayAmount = try container.decodeIfPresent(String.self, forKey: .actualPayAmount) ?? "" + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + customerPhone = try container.decodeIfPresent(String.self, forKey: .customerPhone) ?? "" + userMobileMasked = try container.decodeIfPresent(String.self, forKey: .userMobileMasked) ?? "" + phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? "" + saleUserName = try container.decodeIfPresent(String.self, forKey: .saleUserName) ?? "" + photogRemarkName = try container.decodeIfPresent(String.self, forKey: .photogRemarkName) ?? "" + partnerName = try container.decodeIfPresent(String.self, forKey: .partnerName) ?? "" + } +} + +/// GET /photog/sale-user/invite-info 扫码邀请信息。 +struct SaleUserInviteInfoResponse: Decodable, Equatable { + let saleUserId: Int + let isBound: Bool + let photographerName: String + let photographerPhone: String + let salerName: String + let salerPhone: String + let shareCode: String + let subtitle: String + let title: String + + enum CodingKeys: String, CodingKey { + case saleUserId = "sale_user_id" + case isBound = "is_bound" + case photographerName = "photographer_name" + case photographerPhone = "photographer_phone" + case salerName = "saler_name" + case salerPhone = "saler_phone" + case shareCode = "share_code" + case subtitle + case title + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + saleUserId = try container.decodeIfPresent(Int.self, forKey: .saleUserId) ?? 0 + isBound = try container.decodeIfPresent(Bool.self, forKey: .isBound) ?? false + photographerName = try container.decodeIfPresent(String.self, forKey: .photographerName) ?? "" + photographerPhone = try container.decodeIfPresent(String.self, forKey: .photographerPhone) ?? "" + salerName = try container.decodeIfPresent(String.self, forKey: .salerName) ?? "" + salerPhone = try container.decodeIfPresent(String.self, forKey: .salerPhone) ?? "" + shareCode = try container.decodeIfPresent(String.self, forKey: .shareCode) ?? "" + subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + } + + init( + saleUserId: Int = 0, + isBound: Bool = false, + photographerName: String = "", + photographerPhone: String = "", + salerName: String = "", + salerPhone: String = "", + shareCode: String = "", + subtitle: String = "", + title: String = "" + ) { + self.saleUserId = saleUserId + self.isBound = isBound + self.photographerName = photographerName + self.photographerPhone = photographerPhone + self.salerName = salerName + self.salerPhone = salerPhone + self.shareCode = shareCode + self.subtitle = subtitle + self.title = title + } +} + +struct SaleUserUpdateRemarkRequest: Encodable { + let saleUserId: Int + let photogRemarkName: String + + enum CodingKeys: String, CodingKey { + case saleUserId = "sale_user_id" + case photogRemarkName = "photog_remark_name" + } +} + +struct SaleUserCommissionRateSmsRequest: Encodable { + let phone: String +} + +struct SaleUserCommissionRateUpdateRequest: Encodable { + let saleUserId: Int + let commissionRate: Int + let smsCode: String + + enum CodingKeys: String, CodingKey { + case saleUserId = "sale_user_id" + case commissionRate = "commission_rate" + case smsCode = "sms_code" + } +} diff --git a/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift new file mode 100644 index 0000000..f3547d7 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift @@ -0,0 +1,101 @@ +// +// BindAcquirerViewModel.swift +// suixinkan +// + +import Foundation + +/// 接受邀请 / 绑定获客员 ViewModel,对齐 Android `BindAcquirerViewModel`。 +final class BindAcquirerViewModel { + + private(set) var inviteInfo: SaleUserInviteInfoResponse? + private(set) var isLoading = false + private(set) var isAlreadyBound = false + private(set) var commissionRateDraft = "" + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onBindSuccess: (() -> Void)? + var onShouldPop: (() -> Void)? + + private var saleUserId = 0 + + func initSaleUserId(_ id: Int) { + guard saleUserId != id || (inviteInfo == nil && !isAlreadyBound) else { return } + saleUserId = id + isAlreadyBound = false + commissionRateDraft = "" + inviteInfo = nil + notifyStateChange() + } + + func updateCommissionRateDraft(_ text: String) { + commissionRateDraft = String(text.filter(\.isNumber).prefix(3)) + notifyStateChange() + } + + func loadInviteInfo(api: OrderAPI) async { + guard saleUserId > 0 else { + onShowMessage?("获客员信息无效") + onShouldPop?() + return + } + isLoading = true + notifyStateChange() + do { + let data = try await api.saleUserInviteInfo(saleUserId: String(saleUserId)) + let resolvedSaleUserId = data.saleUserId > 0 ? data.saleUserId : saleUserId + inviteInfo = SaleUserInviteInfoResponse( + saleUserId: resolvedSaleUserId, + isBound: data.isBound, + photographerName: data.photographerName, + photographerPhone: data.photographerPhone, + salerName: data.salerName, + salerPhone: data.salerPhone, + shareCode: data.shareCode, + subtitle: data.subtitle, + title: data.title + ) + if data.isBound { + isAlreadyBound = true + } + } catch { + handleBindError(error.localizedDescription.isEmpty ? "获取邀请信息失败" : error.localizedDescription) + } + isLoading = false + notifyStateChange() + } + + func acceptBind(api: OrderAPI) async { + guard saleUserId > 0, !isAlreadyBound else { return } + guard let commissionRate = Int(commissionRateDraft.trimmingCharacters(in: .whitespacesAndNewlines)) else { + onShowMessage?("请输入分账比例") + return + } + guard (0...100).contains(commissionRate) else { + onShowMessage?("分账比例需在0到100之间") + return + } + do { + try await api.saleUserAcceptBind(saleUserId: saleUserId, commissionRate: commissionRate) + onShowMessage?("绑定成功") + onBindSuccess?() + } catch { + handleBindError(error.localizedDescription.isEmpty ? "绑定失败" : error.localizedDescription) + } + } + + private func handleBindError(_ message: String) { + onShowMessage?(message) + if message.contains("已绑定") { + isAlreadyBound = true + notifyStateChange() + return + } + onShouldPop?() + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift new file mode 100644 index 0000000..8578246 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift @@ -0,0 +1,213 @@ +// +// CooperationAcquirerViewModel.swift +// suixinkan +// + +import Foundation + +/// 合作获客员 ViewModel,对齐 Android `CooperationAcquirerViewModel`。 +final class CooperationAcquirerViewModel { + + private(set) var acquirers: [CooperativeSalerEntity] = [] + private(set) var isRefreshing = false + private(set) var remarkEditingAcquirer: CooperativeSalerEntity? + private(set) var remarkDraft = "" + private(set) var commissionEditingAcquirer: CooperativeSalerEntity? + private(set) var commissionRateDraft = "" + private(set) var commissionSmsCodeDraft = "" + private(set) var commissionSmsCountdown = 0 + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onNavigateBindAcquirer: ((Int) -> Void)? + + private let appStore: AppStore + private var commissionSmsTimerTask: Task? + + init(appStore: AppStore = .shared) { + self.appStore = appStore + } + + deinit { + commissionSmsTimerTask?.cancel() + } + + func loadAcquirers(api: OrderAPI, initial: Bool = false) async { + if initial { isRefreshing = false } + let storeId = appStore.currentStoreId + let storeIdParam = storeId > 0 ? String(storeId) : nil + do { + let response = try await api.cooperativeSalers(storeId: storeIdParam, pageSize: "20") + acquirers = response.list + } catch { + onShowMessage?(error.localizedDescription) + } + isRefreshing = false + notifyStateChange() + } + + func refresh(api: OrderAPI) async { + isRefreshing = true + notifyStateChange() + await loadAcquirers(api: api, initial: false) + } + + func startEditRemark(_ acquirer: CooperativeSalerEntity) { + remarkEditingAcquirer = acquirer + remarkDraft = acquirer.photogRemarkName + notifyStateChange() + } + + func updateRemarkDraft(_ text: String) { + remarkDraft = String(text.prefix(CooperationOrderFeature.remarkNameMaxLength)) + notifyStateChange() + } + + func dismissRemarkDialog() { + remarkEditingAcquirer = nil + remarkDraft = "" + notifyStateChange() + } + + func saveRemark(api: OrderAPI) async { + guard let acquirer = remarkEditingAcquirer else { return } + let saleUserId = acquirer.saleUserId + guard saleUserId > 0 else { return } + let trimmedRemark = remarkDraft.trimmingCharacters(in: .whitespacesAndNewlines) + if let message = CooperationOrderFeature.remarkNameValidationMessage(remarkDraft) { + onShowMessage?(message) + return + } + dismissRemarkDialog() + do { + try await api.saleUserUpdateRemark(saleUserId: saleUserId, photogRemarkName: trimmedRemark) + acquirers = acquirers.map { item in + item.saleUserId == saleUserId ? item.withRemarkName(trimmedRemark) : item + } + onShowMessage?("备注已保存") + notifyStateChange() + } catch { + onShowMessage?(error.localizedDescription) + } + } + + func startEditCommissionRate(_ acquirer: CooperativeSalerEntity) { + stopCommissionSmsTimer() + commissionEditingAcquirer = acquirer + commissionRateDraft = String(acquirer.commissionRate) + commissionSmsCodeDraft = "" + notifyStateChange() + } + + func updateCommissionRateDraft(_ text: String) { + commissionRateDraft = String(text.filter(\.isNumber).prefix(3)) + notifyStateChange() + } + + func updateCommissionSmsCodeDraft(_ text: String) { + commissionSmsCodeDraft = String(text.filter(\.isNumber).prefix(6)) + notifyStateChange() + } + + func dismissCommissionRateDialog() { + commissionEditingAcquirer = nil + commissionRateDraft = "" + commissionSmsCodeDraft = "" + stopCommissionSmsTimer() + notifyStateChange() + } + + func requestCommissionSmsCode(api: OrderAPI) async { + guard let acquirer = commissionEditingAcquirer else { return } + if commissionSmsCountdown > 0 { + onShowMessage?("请在倒计时结束后重新获取") + return + } + let phone = acquirer.salerPhone.isEmpty ? acquirer.displayPhone : acquirer.salerPhone + let trimmedPhone = phone.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPhone.isEmpty else { + onShowMessage?("获客员手机号为空") + return + } + do { + try await api.saleUserCommissionRateSendSmsCode(phone: trimmedPhone) + startCommissionSmsTimer() + onShowMessage?("验证码已发送") + } catch { + onShowMessage?(error.localizedDescription) + } + } + + func saveCommissionRate(api: OrderAPI) async { + guard let acquirer = commissionEditingAcquirer else { return } + let saleUserId = acquirer.saleUserId + guard saleUserId > 0 else { return } + guard let commissionRate = Int(commissionRateDraft.trimmingCharacters(in: .whitespacesAndNewlines)) else { + onShowMessage?("请输入分成比例") + return + } + guard (0...100).contains(commissionRate) else { + onShowMessage?("分成比例需在0到100之间") + return + } + let smsCode = commissionSmsCodeDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !smsCode.isEmpty else { + onShowMessage?("请输入验证码") + return + } + do { + let updated = try await api.saleUserUpdateCommissionRate( + saleUserId: saleUserId, + commissionRate: commissionRate, + smsCode: smsCode + ) + dismissCommissionRateDialog() + if updated.saleUserId > 0 { + acquirers = acquirers.map { item in + item.saleUserId == updated.saleUserId ? updated : item + } + } else { + await loadAcquirers(api: api, initial: false) + } + onShowMessage?("分成比例已修改") + notifyStateChange() + } catch { + onShowMessage?(error.localizedDescription) + } + } + + func processScanResult(_ scanResult: String) { + guard let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 else { + onShowMessage?("请扫描正确的获客员二维码") + return + } + onNavigateBindAcquirer?(saleUserId) + } + + private func startCommissionSmsTimer() { + commissionSmsTimerTask?.cancel() + commissionSmsTimerTask = Task { [weak self] in + guard let self else { return } + for second in stride(from: 60, through: 1, by: -1) { + if Task.isCancelled { return } + commissionSmsCountdown = second + notifyStateChange() + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + if !Task.isCancelled { + commissionSmsCountdown = 0 + notifyStateChange() + } + } + } + + private func stopCommissionSmsTimer() { + commissionSmsTimerTask?.cancel() + commissionSmsTimerTask = nil + commissionSmsCountdown = 0 + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationOrderListViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationOrderListViewModel.swift new file mode 100644 index 0000000..05b6959 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationOrderListViewModel.swift @@ -0,0 +1,129 @@ +// +// CooperationOrderListViewModel.swift +// suixinkan +// + +import Foundation + +/// 合作订单列表 ViewModel,对齐 Android `CooperationOrderListViewModel`。 +final class CooperationOrderListViewModel { + + private(set) var selectedTab = 0 + private(set) var searchText = "" + private(set) var leads: [ReferralLeadEntity] = [] + private(set) var orders: [AcquisitionOrderEntity] = [] + private(set) var isRefreshing = false + private(set) var isLoadingMore = false + private(set) var canLoadMore = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private let pageSize = 10 + private var leadsPage = 1 + private var ordersPage = 1 + private var leadsTotal = 0 + private var ordersTotal = 0 + + func onTabSelected(_ index: Int) { + guard selectedTab != index else { return } + selectedTab = index + notifyStateChange() + } + + func onSearchTextChange(_ text: String) { + searchText = text + notifyStateChange() + } + + func refreshCurrentTab(api: OrderAPI) async { + if selectedTab == 0 { + await refreshLeads(api: api) + } else { + await refreshAcquisitionOrders(api: api) + } + } + + func refreshLeads(api: OrderAPI) async { + leadsPage = 1 + isRefreshing = true + isLoadingMore = false + notifyStateChange() + await loadLeads(api: api) + } + + func refreshAcquisitionOrders(api: OrderAPI) async { + ordersPage = 1 + isRefreshing = true + isLoadingMore = false + notifyStateChange() + await loadAcquisitionOrders(api: api) + } + + func loadMore(api: OrderAPI) async { + guard !isRefreshing, !isLoadingMore, canLoadMore else { return } + isLoadingMore = true + notifyStateChange() + if selectedTab == 0 { + leadsPage += 1 + await loadLeads(api: api) + } else { + ordersPage += 1 + await loadAcquisitionOrders(api: api) + } + } + + func shouldLoadMore(at index: Int) -> Bool { + guard canLoadMore, !isRefreshing, !isLoadingMore else { return false } + let count = selectedTab == 0 ? leads.count : orders.count + return index >= max(0, count - 3) + } + + private func loadLeads(api: OrderAPI) async { + do { + let response = try await api.referralLeads( + page: String(leadsPage), + pageSize: String(pageSize) + ) + leadsTotal = response.total + if leadsPage == 1 { + leads = response.list + } else { + leads.append(contentsOf: response.list) + } + canLoadMore = leads.count < leadsTotal + } catch { + onShowMessage?(error.localizedDescription) + } + isRefreshing = false + isLoadingMore = false + notifyStateChange() + } + + private func loadAcquisitionOrders(api: OrderAPI) async { + let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let response = try await api.acquisitionOrders( + keyword: keyword.isEmpty ? nil : keyword, + page: String(ordersPage), + pageSize: String(pageSize) + ) + ordersTotal = response.total + if ordersPage == 1 { + orders = response.list + } else { + orders.append(contentsOf: response.list) + } + canLoadMore = orders.count < ordersTotal + } catch { + onShowMessage?(error.localizedDescription) + } + isRefreshing = false + isLoadingMore = false + notifyStateChange() + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Home/API/HomeAPI.swift b/suixinkan/Features/Home/API/HomeAPI.swift index eac01aa..2ca625e 100644 --- a/suixinkan/Features/Home/API/HomeAPI.swift +++ b/suixinkan/Features/Home/API/HomeAPI.swift @@ -63,4 +63,29 @@ final class HomeAPI { ) ) } + + /// 拉取待审核权限申请列表。 + func roleApplyPending() async throws -> [RoleApplyPendingResponse] { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/role-apply/all") + ) + } + + /// 拉取全部可选景区。 + func scenicListAll() async throws -> ScenicListAllResponse { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/scenic/list-all") + ) + } + + /// 提交角色权限申请。 + func submitRoleApply(scenicIds: [Int], roleId: Int) async throws -> RoleApplySubmitResult { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/role-apply/submit", + body: RoleApplySubmitRequest(scenicId: scenicIds, roleId: roleId) + ) + ) + } } diff --git a/suixinkan/Features/Home/Models/PermissionApplyModels.swift b/suixinkan/Features/Home/Models/PermissionApplyModels.swift new file mode 100644 index 0000000..77cf31d --- /dev/null +++ b/suixinkan/Features/Home/Models/PermissionApplyModels.swift @@ -0,0 +1,107 @@ +// +// PermissionApplyModels.swift +// suixinkan +// + +import Foundation + +struct RoleApplyPendingResponse: Decodable, Equatable { + let code: String + let roleId: Int + let roleName: String + let scenicList: [RoleApplyScenicItem] + let status: Int + let statusLabel: String + let createdAt: String + let auditNote: String + let auditedAt: String? + let auditedBy: String? + + enum CodingKeys: String, CodingKey { + case code + case roleId = "role_id" + case roleName = "role_name" + case scenicList = "scenic_list" + case status + case statusLabel = "status_label" + case createdAt = "created_at" + case auditNote = "audit_note" + case auditedAt = "audited_at" + case auditedBy = "audited_by" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeIfPresent(String.self, forKey: .code) ?? "" + roleId = try container.decodeIfPresent(Int.self, forKey: .roleId) ?? 0 + roleName = try container.decodeIfPresent(String.self, forKey: .roleName) ?? "" + scenicList = try container.decodeIfPresent([RoleApplyScenicItem].self, forKey: .scenicList) ?? [] + status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0 + statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) ?? "" + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + auditNote = try container.decodeIfPresent(String.self, forKey: .auditNote) ?? "" + auditedAt = try container.decodeIfPresent(String.self, forKey: .auditedAt) + auditedBy = try container.decodeIfPresent(String.self, forKey: .auditedBy) + } +} + +struct RoleApplyScenicItem: Decodable, Equatable { + let id: Int + let name: String +} + +struct ScenicListAllResponse: Decodable, Equatable { + let total: Int + let list: [ScenicListItem] + + init(total: Int = 0, list: [ScenicListItem] = []) { + self.total = total + self.list = list + } +} + +struct ScenicListItem: Decodable, Equatable { + let id: Int + let name: String +} + +struct RoleApplySubmitRequest: Encodable { + let scenicId: [Int] + let roleId: Int + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case roleId = "role_id" + } +} + +struct RoleOption: Equatable, Hashable { + let id: Int + let name: String + let description: String + var isSelected: Bool +} + +struct ScenicOption: Equatable, Hashable { + let id: Int + let name: String + var isSelected: Bool + var isDisabled: Bool +} + +struct RoleApplySubmitResult: Decodable, Equatable { + let code: String + + init(code: String = "") { + self.code = code + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeIfPresent(String.self, forKey: .code) ?? "" + } + + private enum CodingKeys: String, CodingKey { + case code + } +} diff --git a/suixinkan/Features/Home/Models/ScenicSelectionModels.swift b/suixinkan/Features/Home/Models/ScenicSelectionModels.swift new file mode 100644 index 0000000..4555d24 --- /dev/null +++ b/suixinkan/Features/Home/Models/ScenicSelectionModels.swift @@ -0,0 +1,149 @@ +// +// ScenicSelectionModels.swift +// suixinkan +// + +import Foundation + +/// 景区列表展示标签,对齐 Android `openStatus`。 +enum ScenicSelectionOpenStatus: Equatable { + case nearest + case closed + case none +} + +/// 景区选择列表项,对齐 Android `Sceniclistv2Entity` 展示字段。 +struct ScenicSpotDisplayItem: Equatable, Hashable { + let id: Int + let name: String + let coverImg: String + let address: String + let distanceText: String + let openStatus: ScenicSelectionOpenStatus + let isSelected: Bool + + var statusLabel: String? { + switch openStatus { + case .nearest: "距离最近" + case .closed: "暂未营业" + case .none: nil + } + } +} + +/// 景区距离计算,对齐 Android Haversine 逻辑。 +enum ScenicDistanceCalculator { + private static let earthRadiusMeters = 6_371_000.0 + + static func distanceKilometers( + fromLat: Double, + fromLng: Double, + toLat: Double, + toLng: Double + ) -> Double { + let dLat = (toLat - fromLat) * .pi / 180 + let dLng = (toLng - fromLng) * .pi / 180 + let lat1 = fromLat * .pi / 180 + let lat2 = toLat * .pi / 180 + let a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1) * cos(lat2) * sin(dLng / 2) * sin(dLng / 2) + let c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return (earthRadiusMeters * c) / 1000 + } + + static func formatDistanceKilometers(_ kilometers: Double) -> String { + if kilometers <= 0 || !kilometers.isFinite { + return "0" + } + return String(format: "%.2f", kilometers) + } +} + +/// 将 role-permission 景区映射为选择页展示项。 +enum ScenicSpotDisplayMapper { + static func buildItems( + scenicList: [ScenicInfo], + searchQuery: String, + currentLat: Double?, + currentLng: Double?, + selectedScenicId: Int + ) -> [ScenicSpotDisplayItem] { + let hasLocation = (currentLat ?? 0) > 0 && (currentLng ?? 0) > 0 + let searchLower = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + struct ScenicWithDistance { + let item: ScenicSpotDisplayItem + let distance: Double + } + + let mapped = scenicList.map { scenic -> ScenicWithDistance in + let loc = scenic.location + let distance: Double + if let loc, hasLocation, let currentLat, let currentLng { + distance = ScenicDistanceCalculator.distanceKilometers( + fromLat: currentLat, + fromLng: currentLng, + toLat: loc.lat, + toLng: loc.lng + ) + } else if loc == nil, hasLocation { + distance = .greatestFiniteMagnitude + } else { + distance = 0 + } + + let address = loc?.address.isEmpty == false ? (loc?.address ?? "--") : "--" + let distanceText: String + if loc == nil { + distanceText = "--" + } else if distance > 0, distance < .greatestFiniteMagnitude { + distanceText = ScenicDistanceCalculator.formatDistanceKilometers(distance) + } else { + distanceText = "0" + } + + let openStatus: ScenicSelectionOpenStatus = scenic.status == 2 ? .closed : .none + let item = ScenicSpotDisplayItem( + id: scenic.id, + name: scenic.name, + coverImg: scenic.coverImg ?? "", + address: address, + distanceText: distanceText, + openStatus: openStatus, + isSelected: selectedScenicId > 0 && scenic.id == selectedScenicId + ) + return ScenicWithDistance(item: item, distance: distance) + } + + let filtered = mapped.filter { entry in + guard !searchLower.isEmpty else { return true } + return entry.item.name.lowercased().contains(searchLower) + || entry.item.address.lowercased().contains(searchLower) + } + + let sorted: [ScenicWithDistance] + if hasLocation { + sorted = filtered.sorted { $0.distance < $1.distance } + } else { + sorted = filtered + } + + let baseItems = sorted.map(\.item) + guard hasLocation, !baseItems.isEmpty else { return baseItems } + + return baseItems.enumerated().map { index, item in + if index == 0, item.openStatus != .closed { + return ScenicSpotDisplayItem( + id: item.id, + name: item.name, + coverImg: item.coverImg, + address: item.address, + distanceText: item.distanceText, + openStatus: .nearest, + isSelected: item.isSelected + ) + } + return item + } + } +} diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index 6a13062..6b741f5 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -47,7 +47,7 @@ enum HomeRouteHandler { showToast("暂无合作订单权限", from: viewController) return } - showDeveloping(from: viewController) + viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true) case "task_management", "task_management_editor": viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true) default: diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index 4f2d066..f3db0e2 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -212,9 +212,17 @@ final class HomeViewModel { } } - /// 刷新景区名与角色展示状态。 + /// 刷新景区名与角色展示状态,对齐 Android `initCurrentScenicName`。 func refreshLocalDisplayState() { - currentScenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) + let scenicId = appStore.currentScenicId + let scenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) + if scenicId > 0, !scenicName.isEmpty { + currentScenicName = scenicName + } else { + currentScenicName = "" + appStore.currentScenicId = 0 + appStore.currentScenicName = "" + } currentAppRole = appStore.currentAppRole isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false } diff --git a/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift b/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift new file mode 100644 index 0000000..f98df6c --- /dev/null +++ b/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift @@ -0,0 +1,48 @@ +// +// PermissionApplyStatusViewModel.swift +// suixinkan +// + +import Foundation + +/// 权限申请审核状态 ViewModel,对齐 Android `PermissionApplyStatusViewModel`。 +final class PermissionApplyStatusViewModel { + + private(set) var pendingData: RoleApplyPendingResponse? + private(set) var isLoading = false + private(set) var rolePermissionList: [RolePermissionResponse] = [] + + var onStateChange: (() -> Void)? + var onNavigateToEdit: ((RoleApplyPendingResponse) -> Void)? + + private let applyCode: String + private let appStore: AppStore + + init(applyCode: String, appStore: AppStore = .shared) { + self.applyCode = applyCode + self.appStore = appStore + rolePermissionList = appStore.rolePermissionList() + } + + func loadPendingData(api: HomeAPI) async { + isLoading = true + notifyStateChange() + do { + let pendingList = try await api.roleApplyPending() + pendingData = pendingList.first { $0.code == applyCode } + } catch { + pendingData = nil + } + isLoading = false + notifyStateChange() + } + + func navigateToEditIfRejected() { + guard let pendingData, pendingData.status == PermissionApplyPendingStatus.rejected else { return } + onNavigateToEdit?(pendingData) + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift b/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift new file mode 100644 index 0000000..1ce859a --- /dev/null +++ b/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift @@ -0,0 +1,199 @@ +// +// PermissionApplyViewModel.swift +// suixinkan +// + +import Foundation + +/// 权限申请 ViewModel,对齐 Android `PermissionApplyViewModel`。 +final class PermissionApplyViewModel { + + private(set) var availableRoles: [RoleOption] = [] + private(set) var selectedRoleId: Int? + private(set) var selectedRoleName = "" + private(set) var availableScenics: [ScenicOption] = [] + private(set) var selectedScenicIds: [Int] = [] + private(set) var selectedScenicNames: [String] = [] + private(set) var isLoadingScenics = false + private(set) var isSubmitting = false + private(set) var showRolePicker = false + private(set) var showScenicPicker = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onSubmitSuccess: ((String) -> Void)? + + private let appStore: AppStore + + init(appStore: AppStore = .shared) { + self.appStore = appStore + loadRolesFromLocal() + } + + /// 从待审核数据预填表单(status=3 去编辑)。 + func initWithPendingData(_ pending: RoleApplyPendingResponse) { + selectedRoleId = pending.roleId + selectedRoleName = pending.roleName + selectedScenicIds = pending.scenicList.map(\.id) + selectedScenicNames = pending.scenicList.map(\.name) + availableRoles = availableRoles.map { role in + var copy = role + copy.isSelected = role.id == pending.roleId + return copy + } + notifyStateChange() + } + + func loadRolesFromLocal() { + let rolePermissionList = appStore.rolePermissionList() + var seen = Set() + availableRoles = rolePermissionList.compactMap { item in + guard !seen.contains(item.role.id) else { return nil } + seen.insert(item.role.id) + return RoleOption( + id: item.role.id, + name: item.role.name, + description: item.role.notes ?? "", + isSelected: false + ) + } + notifyStateChange() + } + + func showRolePickerSheet() { + showRolePicker = true + notifyStateChange() + } + + func hideRolePickerSheet() { + showRolePicker = false + notifyStateChange() + } + + func selectRole(_ role: RoleOption) { + selectedRoleId = role.id + selectedRoleName = role.name + availableRoles = availableRoles.map { item in + var copy = item + copy.isSelected = item.id == role.id + return copy + } + selectedScenicIds = [] + selectedScenicNames = [] + availableScenics = [] + showRolePicker = false + notifyStateChange() + } + + func showScenicPickerSheet() { + guard selectedRoleId != nil else { + onShowMessage?("请先选择角色") + return + } + showScenicPicker = true + notifyStateChange() + } + + func hideScenicPickerSheet() { + showScenicPicker = false + notifyStateChange() + } + + func loadAvailableScenics(api: HomeAPI?) async { + guard let roleId = selectedRoleId else { return } + guard let api else { return } + isLoadingScenics = true + notifyStateChange() + do { + let response = try await api.scenicListAll() + let existingIds = existingScenicIds(for: roleId) + availableScenics = response.list.map { scenic in + let isExisting = existingIds.contains(scenic.id) + let isPreselected = selectedScenicIds.contains(scenic.id) + return ScenicOption( + id: scenic.id, + name: scenic.name, + isSelected: isExisting || isPreselected, + isDisabled: isExisting + ) + } + } catch { + onShowMessage?("获取景区列表失败") + } + isLoadingScenics = false + notifyStateChange() + } + + func toggleScenic(_ scenic: ScenicOption) { + guard !scenic.isDisabled else { return } + if selectedScenicIds.contains(scenic.id) { + selectedScenicIds.removeAll { $0 == scenic.id } + selectedScenicNames.removeAll { $0 == scenic.name } + } else { + selectedScenicIds.append(scenic.id) + selectedScenicNames.append(scenic.name) + } + availableScenics = availableScenics.map { item in + var copy = item + if !copy.isDisabled { + copy.isSelected = selectedScenicIds.contains(copy.id) + } + return copy + } + notifyStateChange() + } + + func removeSelectedScenic(id: Int, name: String) { + selectedScenicIds.removeAll { $0 == id } + selectedScenicNames.removeAll { $0 == name } + availableScenics = availableScenics.map { item in + var copy = item + if !copy.isDisabled { + copy.isSelected = selectedScenicIds.contains(copy.id) + } + return copy + } + notifyStateChange() + } + + /// 已有角色下已开通的景区列表。 + func existingScenics(for roleId: Int) -> [ScenicInfo] { + appStore.rolePermissionList() + .filter { $0.role.id == roleId } + .flatMap(\.scenic) + } + + func submit(api: HomeAPI) async { + guard let roleId = selectedRoleId else { + onShowMessage?("请先选择角色") + return + } + let newScenicIds = selectedScenicIds.filter { id in + !existingScenicIds(for: roleId).contains(id) + } + guard !newScenicIds.isEmpty else { + onShowMessage?("请至少选择一个景区") + return + } + + isSubmitting = true + notifyStateChange() + do { + let result = try await api.submitRoleApply(scenicIds: newScenicIds, roleId: roleId) + onShowMessage?("提交成功") + onSubmitSuccess?(result.code) + } catch { + onShowMessage?("提交失败,请稍后重试") + } + isSubmitting = false + notifyStateChange() + } + + private func existingScenicIds(for roleId: Int) -> Set { + Set(existingScenics(for: roleId).map(\.id)) + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift b/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift new file mode 100644 index 0000000..69cb4dd --- /dev/null +++ b/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift @@ -0,0 +1,121 @@ +// +// ScenicSelectionViewModel.swift +// suixinkan +// + +import Foundation + +/// 权限申请审核中状态,对齐 Android role-apply pending status。 +enum PermissionApplyPendingStatus { + static let reviewing = 1 + static let rejected = 3 +} + +/// 景区选择 ViewModel,对齐 Android `ScenicSelectionViewModel`。 +final class ScenicSelectionViewModel { + + private(set) var spots: [ScenicSpotDisplayItem] = [] + private(set) var searchQuery = "" + private(set) var currentLocationText = "最近的景区" + private(set) var isLoading = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onSelectionComplete: (() -> Void)? + var onNavigateApply: (() -> Void)? + var onNavigateApplyStatus: ((String) -> Void)? + + private let appStore: AppStore + private let locationProvider: HomeLocationProvider + private var currentLat: Double? + private var currentLng: Double? + + init(appStore: AppStore = .shared, locationProvider: HomeLocationProvider = HomeLocationProvider()) { + self.appStore = appStore + self.locationProvider = locationProvider + } + + func loadFromLocal() { + let scenicList = resolvedScenicList() + spots = ScenicSpotDisplayMapper.buildItems( + scenicList: scenicList, + searchQuery: searchQuery, + currentLat: currentLat, + currentLng: currentLng, + selectedScenicId: appStore.currentScenicId + ) + notifyStateChange() + } + + func onSearchQueryChange(_ query: String) { + searchQuery = query + loadFromLocal() + } + + func startLocation() async { + isLoading = true + notifyStateChange() + do { + let snapshot = try await locationProvider.requestSnapshot() + currentLat = snapshot.latitude + currentLng = snapshot.longitude + let address = snapshot.address.trimmingCharacters(in: .whitespacesAndNewlines) + currentLocationText = address.isEmpty ? "最近的景区" : address + loadFromLocal() + } catch HomeLocationProviderError.permissionDenied { + onShowMessage?("未授予定位权限,无法获取附近景区") + } catch { + onShowMessage?("定位失败: \(error.localizedDescription)") + } + isLoading = false + notifyStateChange() + } + + func selectSpot(_ spot: ScenicSpotDisplayItem) { + appStore.currentScenicId = spot.id + appStore.currentScenicName = spot.name + NotificationCenter.default.post( + name: NotificationName.scenicDidChange, + object: nil, + userInfo: [ + NotificationUserInfoKey.scenicId: spot.id, + NotificationUserInfoKey.scenicName: spot.name, + ] + ) + loadFromLocal() + onSelectionComplete?() + } + + func onApplyNow(api: HomeAPI) async { + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + let pendingList = try await api.roleApplyPending() + if let first = pendingList.first, + first.status == PermissionApplyPendingStatus.reviewing + || first.status == PermissionApplyPendingStatus.rejected { + onNavigateApplyStatus?(first.code) + } else { + onNavigateApply?() + } + } catch { + onNavigateApply?() + } + } + + private func resolvedScenicList() -> [ScenicInfo] { + let cached = appStore.roleScenicList() + if !cached.isEmpty { return cached } + let rolePermissionList = appStore.rolePermissionList() + guard !rolePermissionList.isEmpty else { return [] } + return appStore.matchRolePermissionItem(in: rolePermissionList)?.scenic ?? [] + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Orders/API/OrderAPI.swift b/suixinkan/Features/Orders/API/OrderAPI.swift index 393435d..6bf7d1e 100644 --- a/suixinkan/Features/Orders/API/OrderAPI.swift +++ b/suixinkan/Features/Orders/API/OrderAPI.swift @@ -206,6 +206,93 @@ final class OrderAPI { ) } + func referralLeads(page: String = "1", pageSize: String = "10") async throws -> OrderListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/sale-user/referral-leads", + queryItems: [ + URLQueryItem(name: "page", value: page), + URLQueryItem(name: "page_size", value: pageSize), + ] + ) + ) + } + + func acquisitionOrders(keyword: String? = nil, page: String = "1", pageSize: String = "10") async throws -> OrderListResponse { + var items = [ + URLQueryItem(name: "page", value: page), + URLQueryItem(name: "page_size", value: pageSize), + ] + if let keyword, !keyword.isEmpty { + items.append(URLQueryItem(name: "keyword", value: keyword)) + } + return try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/sale-user/acquisition-orders", + queryItems: items + ) + ) + } + + func saleUserInviteInfo(saleUserId: String) async throws -> SaleUserInviteInfoResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/sale-user/invite-info", + queryItems: [URLQueryItem(name: "sale_user_id", value: saleUserId)] + ) + ) + } + + func saleUserAcceptBind(saleUserId: Int, commissionRate: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/sale-user/accept-bind", + queryItems: [ + URLQueryItem(name: "sale_user_id", value: String(saleUserId)), + URLQueryItem(name: "commission_rate", value: String(commissionRate)), + ] + ) + ) + } + + func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/sale-user/update-remark", + body: SaleUserUpdateRemarkRequest(saleUserId: saleUserId, photogRemarkName: photogRemarkName) + ) + ) + } + + func saleUserCommissionRateSendSmsCode(phone: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/sale-user/commission-rate/send-sms-code", + body: SaleUserCommissionRateSmsRequest(phone: phone) + ) + ) + } + + func saleUserUpdateCommissionRate(saleUserId: Int, commissionRate: Int, smsCode: String) async throws -> CooperativeSalerEntity { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/sale-user/commission-rate", + body: SaleUserCommissionRateUpdateRequest( + saleUserId: saleUserId, + commissionRate: commissionRate, + smsCode: smsCode + ) + ) + ) + } + func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { try await client.send( APIRequest( diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift index 1b2d5a9..ff5ee2e 100644 --- a/suixinkan/Features/Orders/Models/OrderModels.swift +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -789,10 +789,19 @@ struct CooperativeSalerEntity: Decodable, Equatable { let photogRemarkName: String let phone: String let salerPhone: String + let commissionRate: Int + let commissionRateLabel: String + let bindTime: String + let boundAt: String var displayId: Int { saleUserId } var displayName: String { OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) } var displayPhone: String { salerPhone.isEmpty ? phone : salerPhone } + var displayBindTime: String { bindTime.isEmpty ? boundAt : bindTime } + var displayCommissionRate: String { + if !commissionRateLabel.isEmpty { return commissionRateLabel } + return "\(commissionRate)%" + } enum CodingKeys: String, CodingKey { case saleUserId = "sale_user_id" @@ -802,6 +811,67 @@ struct CooperativeSalerEntity: Decodable, Equatable { case photogRemarkName = "photog_remark_name" case phone case salerPhone = "saler_phone" + case commissionRate = "commission_rate" + case commissionRateLabel = "commission_rate_label" + case bindTime = "bind_time" + case boundAt = "bound_at" + } + + init( + saleUserId: Int = 0, + bindingId: Int = 0, + name: String = "", + salerName: String = "", + photogRemarkName: String = "", + phone: String = "", + salerPhone: String = "", + commissionRate: Int = 0, + commissionRateLabel: String = "", + bindTime: String = "", + boundAt: String = "" + ) { + self.saleUserId = saleUserId + self.bindingId = bindingId + self.name = name + self.salerName = salerName + self.photogRemarkName = photogRemarkName + self.phone = phone + self.salerPhone = salerPhone + self.commissionRate = commissionRate + self.commissionRateLabel = commissionRateLabel + self.bindTime = bindTime + self.boundAt = boundAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + saleUserId = try container.decodeIfPresent(Int.self, forKey: .saleUserId) ?? 0 + bindingId = try container.decodeIfPresent(Int.self, forKey: .bindingId) ?? 0 + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + salerName = try container.decodeIfPresent(String.self, forKey: .salerName) ?? "" + photogRemarkName = try container.decodeIfPresent(String.self, forKey: .photogRemarkName) ?? "" + phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? "" + salerPhone = try container.decodeIfPresent(String.self, forKey: .salerPhone) ?? "" + commissionRate = try container.decodeIfPresent(Int.self, forKey: .commissionRate) ?? 0 + commissionRateLabel = try container.decodeIfPresent(String.self, forKey: .commissionRateLabel) ?? "" + bindTime = try container.decodeIfPresent(String.self, forKey: .bindTime) ?? "" + boundAt = try container.decodeIfPresent(String.self, forKey: .boundAt) ?? "" + } + + func withRemarkName(_ remark: String) -> CooperativeSalerEntity { + CooperativeSalerEntity( + saleUserId: saleUserId, + bindingId: bindingId, + name: name, + salerName: salerName, + photogRemarkName: remark, + phone: phone, + salerPhone: salerPhone, + commissionRate: commissionRate, + commissionRateLabel: commissionRateLabel, + bindTime: bindTime, + boundAt: boundAt + ) } } @@ -819,6 +889,7 @@ struct ReferralOrderEntity: Decodable, Equatable { let createdAt: String let status: Int let statusLabel: String + let images: [String] var displayReferralOrderNo: String { referralOrderNo.isEmpty ? orderNo : referralOrderNo } @@ -831,6 +902,20 @@ struct ReferralOrderEntity: Decodable, Equatable { case createdAt = "created_at" case status case statusLabel = "status_label" + case images + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + referralOrderNo = try container.decodeIfPresent(String.self, forKey: .referralOrderNo) ?? "" + orderNo = try container.decodeIfPresent(String.self, forKey: .orderNo) ?? "" + userMobile = try container.decodeIfPresent(String.self, forKey: .userMobile) ?? "" + remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? "" + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0 + statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) ?? "" + images = (try? container.decode([String].self, forKey: .images)) ?? [] } } diff --git a/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift b/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift index 3f03ab1..956777c 100644 --- a/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift +++ b/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift @@ -15,6 +15,7 @@ final class MainTabScanHandler: NSObject, CLLocationManagerDelegate { var onShowWriteOff: ((WriteOffOrderResponse) -> Void)? var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)? var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)? + var onNavigateBindAcquirer: ((Int) -> Void)? private let api: OrderAPI private let locationManager = CLLocationManager() @@ -64,7 +65,7 @@ final class MainTabScanHandler: NSObject, CLLocationManagerDelegate { await fetchSpotsForMultiVerify(pending: pending) case SaleUserQrParser.methodSaleUserBind: if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 { - onShowMessage?("获客员绑定功能待接入") + onNavigateBindAcquirer?(id) } else { onShowMessage?("请扫描正确的获客员二维码") } diff --git a/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift index e3bcf94..3b57199 100644 --- a/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift @@ -20,9 +20,59 @@ struct OrderSourcePerson: Equatable { struct OrderSourceLead: Equatable { let key: String - let displayText: String - let userMobile: String - let createdAt: String + let userPhone: String + let remark: String + let images: [String] +} + +enum OrderSourceMapping { + + /// 将 API 带客单实体映射为订单来源选择项,对齐 Android `toOrderSourceLead()`。 + static func lead(from entity: ReferralOrderEntity) -> OrderSourceLead { + let orderNo = entity.displayReferralOrderNo + let remark: String + if !entity.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + remark = entity.remark + } else if !entity.statusLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + remark = entity.statusLabel + } else { + remark = "带客单 \(orderNo)" + } + return OrderSourceLead( + key: orderNo, + userPhone: entity.userMobile, + remark: remark, + images: entity.images + ) + } + + /// 将已绑定带客单实体映射为订单来源选择项,对齐 Android `toOrderSourceSelection()`。 + static func selection(from referral: BoundReferralOrderEntity) -> OrderSourceSelection? { + let referralNo = referral.orderNo.trimmingCharacters(in: .whitespacesAndNewlines) + guard !referralNo.isEmpty else { return nil } + + let personName = referral.displaySalerName.isEmpty ? "—" : referral.displaySalerName + let person = OrderSourcePerson( + key: referral.saleUserId > 0 ? String(referral.saleUserId) : "", + name: personName, + phone: referral.salerPhone + ) + let remark: String + if !referral.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + remark = referral.remark + } else if !referral.displayText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + remark = referral.displayText + } else { + remark = "带客单号 \(referralNo)" + } + let lead = OrderSourceLead( + key: referralNo, + userPhone: referral.userMobile, + remark: remark, + images: [] + ) + return OrderSourceSelection(person: person, lead: lead) + } } struct OrderSourceSelection: Equatable { @@ -54,13 +104,14 @@ final class OrderListViewModel { private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:] private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:] private(set) var loadingOrderSourceLeadsFor: String? + private(set) var isLoadingOrderSourcePeople = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? + var onNavigateBindAcquirer: ((Int) -> Void)? private var currentPage = 1 private var totalCount = 0 - private var loadingOrderSourcePeople = false private let apiDateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -188,7 +239,7 @@ final class OrderListViewModel { return case SaleUserQrParser.methodSaleUserBind: if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 { - onShowMessage?("获客员绑定功能待接入") + onNavigateBindAcquirer?(saleUserId) } else { onShowMessage?("请扫描正确的获客员二维码") } @@ -287,27 +338,27 @@ final class OrderListViewModel { } 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) + orderSourceSelection(orderNumber: order.orderNumber, referralOrder: order.referralOrder) + } + + /// 按订单号读取当前绑定状态,供订单来源 Sheet 使用。 + func orderSourceSelection(orderNumber: String, referralOrder: BoundReferralOrderEntity? = nil) -> OrderSourceSelection? { + if let selection = orderSourceSelections[orderNumber] { return selection } + if let referral = referralOrder ?? orderList.first(where: { $0.orderNumber == orderNumber })?.referralOrder { + return OrderSourceMapping.selection(from: referral) + } + return nil } func onOrderSourceSheetOpen(api: OrderAPI) async { orderSourceLeads = [:] - guard !loadingOrderSourcePeople else { return } - loadingOrderSourcePeople = true - defer { loadingOrderSourcePeople = false } + guard !isLoadingOrderSourcePeople else { return } + isLoadingOrderSourcePeople = true + notifyStateChange() + defer { + isLoadingOrderSourcePeople = false + notifyStateChange() + } let storeId = AppStore.shared.currentStoreId let storeIdParam = storeId > 0 ? String(storeId) : nil do { @@ -331,15 +382,7 @@ final class OrderListViewModel { } 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 + orderSourceLeads[person.key] = response.list.map(OrderSourceMapping.lead(from:)) notifyStateChange() } catch { onShowMessage?(error.localizedDescription) diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift index afe7f10..b172e62 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -42,6 +42,23 @@ final class ProfileViewModel { return name.isEmpty ? "--" : name } + /// 当前账号类型文案,如「门店账号」「景区账号」。 + var accountTypeLabel: String { + let type = AppStore.shared.accountType.trimmingCharacters(in: .whitespacesAndNewlines) + if type == V9StoreUser.accountTypeValue { + return "门店账号" + } + if type == V9ScenicUser.accountTypeValue { + return "景区账号" + } + return "--" + } + + /// 当前是否为门店账号。 + var isStoreAccount: Bool { + AppStore.shared.accountType == V9StoreUser.accountTypeValue + } + var currentScenicName: String { let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? "--" : name diff --git a/suixinkan/Theme/Modules/OrderTokens.swift b/suixinkan/Theme/Modules/OrderTokens.swift index 448d0d4..f2847af 100644 --- a/suixinkan/Theme/Modules/OrderTokens.swift +++ b/suixinkan/Theme/Modules/OrderTokens.swift @@ -21,6 +21,8 @@ enum OrderTokens { static let giftBorder = UIColor(hex: 0xE5E7EB) static let sourceBorder = UIColor(hex: 0xE6EAF0) static let sourceBackground = UIColor(hex: 0xF9FAFC) + static let sourceDivider = UIColor(hex: 0xE9EDF3) + static let sourceLeadPlaceholder = UIColor(hex: 0xF3F5F8) static let depositSuccess = UIColor(hex: 0x27AE60) static let depositSuccessBackground = UIColor(hex: 0xE8F8F0) static let depositRefunded = UIColor(hex: 0x6B7280) diff --git a/suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift b/suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift new file mode 100644 index 0000000..7f86f02 --- /dev/null +++ b/suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift @@ -0,0 +1,196 @@ +// +// BindAcquirerViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 接受邀请 / 绑定获客员页,对齐 Android `BindAcquirerScreen`。 +final class BindAcquirerViewController: BaseViewController { + + var onBindSuccess: (() -> Void)? + + private let saleUserId: Int + private let viewModel = BindAcquirerViewModel() + private let orderAPI = NetworkServices.shared.orderAPI + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let headerCard = UIView() + private let headerIcon = UIImageView() + private let headerTitleLabel = UILabel() + private let infoCard = UIView() + private let salerRow = CooperationInfoRowView(label: "获客员") + private let phoneRow = CooperationInfoRowView(label: "手机号") + private let commissionField = UITextField() + private let acceptButton = AppButton(title: "接受邀请") + private let loadingIndicator = UIActivityIndicatorView(style: .large) + private let bottomBar = UIView() + + init(saleUserId: Int) { + self.saleUserId = saleUserId + 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() { + contentStack.axis = .vertical + contentStack.spacing = AppSpacing.sm + + headerCard.backgroundColor = AppColor.primary + headerCard.layer.cornerRadius = AppRadius.md + headerIcon.image = UIImage(systemName: "person.fill") + headerIcon.tintColor = .white + headerTitleLabel.text = "邀请信息" + headerTitleLabel.font = .systemFont(ofSize: 16, weight: .bold) + headerTitleLabel.textColor = .white + + infoCard.backgroundColor = .white + infoCard.layer.cornerRadius = AppRadius.md + + commissionField.placeholder = "请输入分账比例(0-100)" + commissionField.keyboardType = .numberPad + commissionField.borderStyle = .roundedRect + commissionField.font = .systemFont(ofSize: 15) + + bottomBar.backgroundColor = .white + acceptButton.addTarget(self, action: #selector(acceptTapped), for: .touchUpInside) + + loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + + view.addSubview(scrollView) + view.addSubview(bottomBar) + scrollView.addSubview(contentStack) + bottomBar.addSubview(acceptButton) + scrollView.addSubview(loadingIndicator) + + contentStack.addArrangedSubview(headerCard) + contentStack.addArrangedSubview(infoCard) + + headerCard.addSubview(headerIcon) + headerCard.addSubview(headerTitleLabel) + + let infoStack = UIStackView(arrangedSubviews: [salerRow, phoneRow, commissionField]) + infoStack.axis = .vertical + infoStack.spacing = AppSpacing.sm + infoCard.addSubview(infoStack) + infoStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + } + + headerIcon.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.md) + make.centerY.equalToSuperview() + make.size.equalTo(24) + } + headerTitleLabel.snp.makeConstraints { make in + make.leading.equalTo(headerIcon.snp.trailing).offset(AppSpacing.sm) + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.top.bottom.equalToSuperview().inset(AppSpacing.md) + } + headerCard.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(56) + } + commissionField.snp.makeConstraints { make in + make.height.equalTo(44) + } + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + acceptButton.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(scrollView) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onBindSuccess = { [weak self] in + Task { @MainActor in + if let callback = self?.onBindSuccess { + callback() + } else { + self?.navigationController?.popViewController(animated: true) + } + } + } + viewModel.onShouldPop = { [weak self] in + Task { @MainActor in self?.navigationController?.popViewController(animated: true) } + } + commissionField.addTarget(self, action: #selector(commissionChanged), for: .editingChanged) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { + showToast("暂无合作订单权限") + navigationController?.popViewController(animated: false) + return + } + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.initSaleUserId(saleUserId) + Task { await viewModel.loadInviteInfo(api: orderAPI) } + } + + @objc private func commissionChanged() { + viewModel.updateCommissionRateDraft(commissionField.text ?? "") + } + + @objc private func acceptTapped() { + Task { await viewModel.acceptBind(api: orderAPI) } + } + + private func applyViewModel() { + if viewModel.isLoading, viewModel.inviteInfo == nil { + loadingIndicator.startAnimating() + contentStack.isHidden = true + bottomBar.isHidden = true + return + } + loadingIndicator.stopAnimating() + contentStack.isHidden = false + bottomBar.isHidden = false + + if let info = viewModel.inviteInfo { + title = info.title.isEmpty ? "接受邀请" : info.title + salerRow.setValue(info.salerName.isEmpty ? "—" : info.salerName) + phoneRow.setValue(info.salerPhone.isEmpty ? "—" : info.salerPhone) + } + + commissionField.text = viewModel.commissionRateDraft + commissionField.isEnabled = !viewModel.isAlreadyBound + acceptButton.isEnabled = !viewModel.isAlreadyBound + acceptButton.setTitle(viewModel.isAlreadyBound ? "已绑定获客员" : "接受邀请", for: .normal) + } +} diff --git a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift new file mode 100644 index 0000000..1f3559f --- /dev/null +++ b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift @@ -0,0 +1,231 @@ +// +// CooperationAcquirerViewController.swift +// suixinkan +// + +import AVFoundation +import SnapKit +import UIKit + +/// 合作获客员页,对齐 Android `CooperationAcquirerScreen`。 +final class CooperationAcquirerViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate { + + private let viewModel = CooperationAcquirerViewModel() + private let orderAPI = NetworkServices.shared.orderAPI + + private let tableView = UITableView(frame: .zero, style: .plain) + private let refreshControl = UIRefreshControl() + private let emptyLabel = UILabel() + private var shouldRefreshOnAppear = false + + override func setupNavigationBar() { + title = "合作获客员" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "扫码绑定", + style: .plain, + target: self, + action: #selector(startScanBind) + ) + navigationItem.rightBarButtonItem?.tintColor = AppColor.primary + } + + override func setupUI() { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier) + tableView.refreshControl = refreshControl + + emptyLabel.text = "暂无合作获客员" + emptyLabel.font = .systemFont(ofSize: 14) + emptyLabel.textColor = AppColor.textTertiary + emptyLabel.textAlignment = .center + emptyLabel.isHidden = true + + view.addSubview(tableView) + view.addSubview(emptyLabel) + } + + override func setupConstraints() { + tableView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onNavigateBindAcquirer = { [weak self] saleUserId in + Task { @MainActor in self?.pushBindAcquirer(saleUserId: saleUserId) } + } + refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { + showToast("暂无合作订单权限") + navigationController?.popViewController(animated: false) + return + } + if shouldRefreshOnAppear { + shouldRefreshOnAppear = false + Task { await viewModel.refresh(api: orderAPI) } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.loadAcquirers(api: orderAPI, initial: true) } + } + + @objc private func startScanBind() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + presentScanner() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + Task { @MainActor in + if granted { + self?.presentScanner() + } + } + } + default: + showToast("需要相机权限才能扫码") + } + } + + @objc private func refreshTriggered() { + Task { + await viewModel.refresh(api: orderAPI) + refreshControl.endRefreshing() + } + } + + private func presentScanner() { + let scanner = QRCodeScannerViewController() + scanner.onScanResult = { [weak self] result in + self?.viewModel.processScanResult(result) + } + let nav = UINavigationController(rootViewController: scanner) + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true) + } + + private func pushBindAcquirer(saleUserId: Int) { + let controller = BindAcquirerViewController(saleUserId: saleUserId) + controller.onBindSuccess = { [weak self] in + self?.shouldRefreshOnAppear = true + self?.navigationController?.popViewController(animated: true) + } + if presentedViewController != nil { + dismiss(animated: true) { [weak self] in + self?.navigationController?.pushViewController(controller, animated: true) + } + return + } + navigationController?.pushViewController(controller, animated: true) + } + + private func applyViewModel() { + emptyLabel.isHidden = !viewModel.acquirers.isEmpty + tableView.reloadData() + } + + private func presentRemarkDialog(for acquirer: CooperativeSalerEntity) { + viewModel.startEditRemark(acquirer) + let alert = UIAlertController(title: "修改备注", message: "最多\(CooperationOrderFeature.remarkNameMaxLength)个字符", preferredStyle: .alert) + alert.addTextField { [weak self] field in + field.text = self?.viewModel.remarkDraft + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in + self?.viewModel.dismissRemarkDialog() + }) + alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self] _ in + guard let self else { return } + if let text = alert.textFields?.first?.text { + self.viewModel.updateRemarkDraft(text) + } + Task { await self.viewModel.saveRemark(api: self.orderAPI) } + }) + present(alert, animated: true) + } + + private func presentCommissionDialog(for acquirer: CooperativeSalerEntity) { + viewModel.startEditCommissionRate(acquirer) + let alert = UIAlertController(title: "修改分成比例", message: nil, preferredStyle: .alert) + alert.addTextField { [weak self] field in + field.placeholder = "分成比例 0-100" + field.keyboardType = .numberPad + field.text = self?.viewModel.commissionRateDraft + } + alert.addTextField { [weak self] field in + field.placeholder = "验证码" + field.keyboardType = .numberPad + field.text = self?.viewModel.commissionSmsCodeDraft + } + let phone = acquirer.salerPhone.isEmpty ? acquirer.displayPhone : acquirer.salerPhone + alert.message = "验证码将发送至 \(CooperationOrderPhoneMask.mask(phone))" + + alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in + self?.viewModel.dismissCommissionRateDialog() + }) + alert.addAction(UIAlertAction(title: "获取验证码", style: .default) { [weak self] _ in + guard let self else { return } + if let rateText = alert.textFields?.first?.text { + self.viewModel.updateCommissionRateDraft(rateText) + } + if let codeText = alert.textFields?.last?.text { + self.viewModel.updateCommissionSmsCodeDraft(codeText) + } + Task { await self.viewModel.requestCommissionSmsCode(api: self.orderAPI) } + }) + alert.addAction(UIAlertAction(title: "确认修改", style: .default) { [weak self] _ in + guard let self else { return } + if let rateText = alert.textFields?.first?.text { + self.viewModel.updateCommissionRateDraft(rateText) + } + if let codeText = alert.textFields?.last?.text { + self.viewModel.updateCommissionSmsCodeDraft(codeText) + } + Task { await self.viewModel.saveCommissionRate(api: self.orderAPI) } + }) + present(alert, animated: true) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.acquirers.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: CooperationAcquirerCell.reuseIdentifier, + for: indexPath + ) as? CooperationAcquirerCell else { + return UITableViewCell() + } + let acquirer = viewModel.acquirers[indexPath.row] + cell.configure(with: acquirer) + cell.onEditRemark = { [weak self] in + self?.presentRemarkDialog(for: acquirer) + } + cell.onEditCommission = { [weak self] in + self?.presentCommissionDialog(for: acquirer) + } + cell.onCall = { + let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines) + guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return } + UIApplication.shared.open(url) + } + return cell + } +} diff --git a/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift b/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift new file mode 100644 index 0000000..9d3ae38 --- /dev/null +++ b/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift @@ -0,0 +1,170 @@ +// +// CooperationOrderListViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 合作订单列表页,对齐 Android `CooperationOrderListScreen`。 +final class CooperationOrderListViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate { + + private let viewModel = CooperationOrderListViewModel() + private let orderAPI = NetworkServices.shared.orderAPI + + private let tabBarView = CooperationOrderTabBar() + private let searchBar = CooperationOrderSearchBar() + private let tableView = UITableView(frame: .zero, style: .plain) + private let refreshControl = UIRefreshControl() + private let emptyLabel = UILabel() + private var isApplyingTab = false + + override func setupNavigationBar() { + title = "合作订单" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "合作获客员", + style: .plain, + target: self, + action: #selector(openAcquirer) + ) + navigationItem.rightBarButtonItem?.tintColor = AppColor.primary + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + searchBar.isHidden = true + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.register(ReferralLeadCell.self, forCellReuseIdentifier: ReferralLeadCell.reuseIdentifier) + tableView.register(AcquisitionOrderCell.self, forCellReuseIdentifier: AcquisitionOrderCell.reuseIdentifier) + tableView.refreshControl = refreshControl + + emptyLabel.text = "暂无相关合作订单" + emptyLabel.font = .systemFont(ofSize: 14) + emptyLabel.textColor = AppColor.textTertiary + emptyLabel.textAlignment = .center + emptyLabel.isHidden = true + + view.addSubview(tabBarView) + view.addSubview(searchBar) + view.addSubview(tableView) + view.addSubview(emptyLabel) + } + + override func setupConstraints() { + tabBarView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } + searchBar.snp.makeConstraints { make in + make.top.equalTo(tabBarView.snp.bottom) + make.leading.trailing.equalToSuperview() + } + tableView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + + tabBarView.onTabSelected = { [weak self] index in + guard let self else { return } + self.viewModel.onTabSelected(index) + Task { await self.viewModel.refreshCurrentTab(api: self.orderAPI) } + } + searchBar.onSearchTextChange = { [weak self] text in + self?.viewModel.onSearchTextChange(text) + } + searchBar.onSearch = { [weak self] in + guard let self else { return } + Task { await self.viewModel.refreshAcquisitionOrders(api: self.orderAPI) } + } + refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { + showToast("暂无合作订单权限") + navigationController?.popViewController(animated: false) + return + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + Task { await viewModel.refreshCurrentTab(api: orderAPI) } + } + + @objc private func openAcquirer() { + navigationController?.pushViewController(CooperationAcquirerViewController(), animated: true) + } + + @objc private func refreshTriggered() { + Task { + await viewModel.refreshCurrentTab(api: orderAPI) + refreshControl.endRefreshing() + } + } + + private func applyViewModel() { + if !isApplyingTab { + isApplyingTab = true + tabBarView.setSelectedTab(viewModel.selectedTab) + isApplyingTab = false + } + searchBar.isHidden = viewModel.selectedTab != 1 + searchBar.textField.text = viewModel.searchText + + let isEmpty = viewModel.selectedTab == 0 ? viewModel.leads.isEmpty : viewModel.orders.isEmpty + emptyLabel.isHidden = !isEmpty || viewModel.isRefreshing + tableView.reloadData() + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.selectedTab == 0 ? viewModel.leads.count : viewModel.orders.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if viewModel.selectedTab == 0 { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: ReferralLeadCell.reuseIdentifier, + for: indexPath + ) as? ReferralLeadCell else { + return UITableViewCell() + } + cell.configure(with: viewModel.leads[indexPath.row]) + cell.onImageTap = { [weak self] index, urls in + let preview = ImagePreviewViewController(imageURLs: urls, startIndex: index) + self?.present(preview, animated: true) + } + return cell + } + guard let cell = tableView.dequeueReusableCell( + withIdentifier: AcquisitionOrderCell.reuseIdentifier, + for: indexPath + ) as? AcquisitionOrderCell else { + return UITableViewCell() + } + cell.configure(with: viewModel.orders[indexPath.row]) + return cell + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard viewModel.shouldLoadMore(at: indexPath.row) else { return } + Task { await viewModel.loadMore(api: orderAPI) } + } +} diff --git a/suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift b/suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift new file mode 100644 index 0000000..fb244c2 --- /dev/null +++ b/suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift @@ -0,0 +1,102 @@ +// +// ImagePreviewViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 图片全屏预览,对齐 Android `MediaPreviewDialog`。 +final class ImagePreviewViewController: UIViewController { + + private let imageURLs: [String] + private let startIndex: Int + private let scrollView = UIScrollView() + private let pageControl = UIPageControl() + + init(imageURLs: [String], startIndex: Int) { + self.imageURLs = imageURLs + self.startIndex = min(max(0, startIndex), max(0, imageURLs.count - 1)) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + scrollView.isPagingEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.delegate = self + + pageControl.numberOfPages = imageURLs.count + pageControl.currentPage = startIndex + pageControl.isHidden = imageURLs.count <= 1 + + let closeButton = UIButton(type: .system) + closeButton.setImage(UIImage(systemName: "xmark"), for: .normal) + closeButton.tintColor = .white + closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + + view.addSubview(scrollView) + view.addSubview(pageControl) + view.addSubview(closeButton) + + scrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + pageControl.snp.makeConstraints { make in + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md) + make.centerX.equalToSuperview() + } + closeButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.size.equalTo(AppSpacing.minTouchTarget) + } + + imageURLs.enumerated().forEach { index, url in + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.loadRemoteImage(urlString: url, placeholderColor: .darkGray) + scrollView.addSubview(imageView) + imageView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.width.equalTo(view.snp.width) + make.height.equalTo(view.snp.height) + make.leading.equalToSuperview().offset(CGFloat(index) * view.bounds.width) + } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + scrollView.contentSize = CGSize(width: view.bounds.width * CGFloat(imageURLs.count), height: view.bounds.height) + scrollView.setContentOffset(CGPoint(x: view.bounds.width * CGFloat(startIndex), y: 0), animated: false) + scrollView.subviews.enumerated().forEach { index, subview in + subview.frame = CGRect( + x: view.bounds.width * CGFloat(index), + y: 0, + width: view.bounds.width, + height: view.bounds.height + ) + } + } + + @objc private func closeTapped() { + dismiss(animated: true) + } +} + +extension ImagePreviewViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard view.bounds.width > 0 else { return } + let page = Int(round(scrollView.contentOffset.x / view.bounds.width)) + pageControl.currentPage = page + } +} diff --git a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift new file mode 100644 index 0000000..a618354 --- /dev/null +++ b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift @@ -0,0 +1,595 @@ +// +// CooperationOrderViews.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// 合作订单 Tab 切换栏。 +final class CooperationOrderTabBar: UIView { + + var onTabSelected: ((Int) -> Void)? + + private let stack = UIStackView() + private var buttons: [UIButton] = [] + private var selectedIndex = 0 + private let indicator = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + stack.axis = .horizontal + stack.distribution = .fillEqually + addSubview(stack) + addSubview(indicator) + indicator.backgroundColor = AppColor.primary + indicator.layer.cornerRadius = 1.5 + + ["获客员线索", "获客订单"].enumerated().forEach { index, title in + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: index == 0 ? .bold : .regular) + button.setTitleColor(index == 0 ? AppColor.primary : AppColor.textSecondary, for: .normal) + button.tag = index + button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) + buttons.append(button) + stack.addArrangedSubview(button) + } + + stack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalTo(48) + } + layoutIndicator(animated: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setSelectedTab(_ index: Int) { + guard selectedIndex != index else { return } + selectedIndex = index + updateButtonStyles() + layoutIndicator(animated: true) + } + + @objc private func tabTapped(_ sender: UIButton) { + setSelectedTab(sender.tag) + onTabSelected?(sender.tag) + } + + private func updateButtonStyles() { + buttons.enumerated().forEach { index, button in + let selected = index == selectedIndex + button.setTitleColor(selected ? AppColor.primary : AppColor.textSecondary, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: selected ? .bold : .regular) + } + } + + private func layoutIndicator(animated: Bool) { + guard !buttons.isEmpty else { return } + let button = buttons[selectedIndex] + let apply = { + self.indicator.snp.remakeConstraints { make in + make.bottom.equalToSuperview() + make.height.equalTo(3) + make.width.equalTo(button.snp.width).multipliedBy(0.5) + make.centerX.equalTo(button) + } + self.layoutIfNeeded() + } + if animated { + UIView.animate(withDuration: 0.2, animations: apply) + } else { + apply() + } + } +} + +/// 获客订单搜索栏。 +final class CooperationOrderSearchBar: UIView, UITextFieldDelegate { + + var onSearchTextChange: ((String) -> Void)? + var onSearch: (() -> Void)? + + let textField = UITextField() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + + let container = UIView() + container.backgroundColor = AppColor.inputBackground + container.layer.cornerRadius = AppRadius.sm + + let icon = UIImageView(image: UIImage(systemName: "magnifyingglass")) + icon.tintColor = AppColor.textTertiary + + textField.placeholder = "名称搜索" + textField.font = .systemFont(ofSize: 14) + textField.textColor = AppColor.textPrimary + textField.returnKeyType = .search + textField.delegate = self + textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) + + addSubview(container) + container.addSubview(icon) + container.addSubview(textField) + + snp.makeConstraints { make in + make.height.equalTo(62) + } + container.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.centerY.equalToSuperview() + make.height.equalTo(38) + } + icon.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.sm) + make.centerY.equalToSuperview() + make.size.equalTo(18) + } + textField.snp.makeConstraints { make in + make.leading.equalTo(icon.snp.trailing).offset(AppSpacing.xs) + make.trailing.equalToSuperview().inset(AppSpacing.sm) + make.centerY.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func textChanged() { + onSearchTextChange?(textField.text ?? "") + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + onSearch?() + return true + } +} + +/// 获客员线索 Cell。 +final class ReferralLeadCell: UITableViewCell { + static let reuseIdentifier = "ReferralLeadCell" + + var onImageTap: ((Int, [String]) -> Void)? + + private let cardView = UIView() + private let phoneLabel = UILabel() + private let phoneValueLabel = UILabel() + private let timeLabel = UILabel() + private let salerRow = CooperationInfoRowView(label: "获客员") + private let remarkTitleLabel = UILabel() + private let remarkLabel = UILabel() + private let imageTitleLabel = UILabel() + private let imageStack = UIStackView() + private let emptyImageLabel = UILabel() + private var imageURLs: [String] = [] + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.lg + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.04 + cardView.layer.shadowOffset = CGSize(width: 0, height: 1) + cardView.layer.shadowRadius = 2 + + phoneLabel.text = "用户手机号" + phoneLabel.font = .systemFont(ofSize: 13) + phoneLabel.textColor = AppColor.textTertiary + phoneValueLabel.font = .systemFont(ofSize: 15, weight: .medium) + phoneValueLabel.textColor = AppColor.textPrimary + timeLabel.font = .systemFont(ofSize: 11) + timeLabel.textColor = AppColor.textTertiary + timeLabel.textAlignment = .right + + remarkTitleLabel.text = "备注" + remarkTitleLabel.font = .systemFont(ofSize: 13) + remarkTitleLabel.textColor = AppColor.textTertiary + remarkLabel.font = .systemFont(ofSize: 14) + remarkLabel.textColor = AppColor.textPrimary + remarkLabel.numberOfLines = 0 + + imageTitleLabel.text = "图片" + imageTitleLabel.font = .systemFont(ofSize: 13) + imageTitleLabel.textColor = AppColor.textTertiary + + imageStack.axis = .horizontal + imageStack.spacing = AppSpacing.xs + imageStack.distribution = .fillEqually + + emptyImageLabel.text = "暂无图片" + emptyImageLabel.font = .systemFont(ofSize: 13) + emptyImageLabel.textColor = AppColor.textTertiary + emptyImageLabel.textAlignment = .center + emptyImageLabel.backgroundColor = UIColor(hex: 0xF7F8FA) + emptyImageLabel.layer.cornerRadius = AppRadius.sm + emptyImageLabel.clipsToBounds = true + emptyImageLabel.isHidden = true + + contentView.addSubview(cardView) + cardView.addSubview(phoneLabel) + cardView.addSubview(phoneValueLabel) + cardView.addSubview(timeLabel) + cardView.addSubview(salerRow) + cardView.addSubview(remarkTitleLabel) + cardView.addSubview(remarkLabel) + cardView.addSubview(imageTitleLabel) + cardView.addSubview(imageStack) + cardView.addSubview(emptyImageLabel) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)) + } + phoneLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(14) + } + phoneValueLabel.snp.makeConstraints { make in + make.leading.equalTo(phoneLabel.snp.trailing).offset(14) + make.centerY.equalTo(phoneLabel) + } + timeLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(14) + make.centerY.equalTo(phoneLabel) + make.leading.greaterThanOrEqualTo(phoneValueLabel.snp.trailing).offset(AppSpacing.xs) + } + salerRow.snp.makeConstraints { make in + make.top.equalTo(phoneLabel.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview().inset(14) + } + remarkTitleLabel.snp.makeConstraints { make in + make.top.equalTo(salerRow.snp.bottom).offset(10) + make.leading.equalToSuperview().offset(14) + } + remarkLabel.snp.makeConstraints { make in + make.top.equalTo(remarkTitleLabel.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview().inset(14) + } + imageTitleLabel.snp.makeConstraints { make in + make.top.equalTo(remarkLabel.snp.bottom).offset(10) + make.leading.equalToSuperview().offset(14) + } + imageStack.snp.makeConstraints { make in + make.top.equalTo(imageTitleLabel.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview().inset(14) + make.height.equalTo(imageStack.snp.width).multipliedBy(1.0 / 3.0).offset(-AppSpacing.xs * 2 / 3) + make.bottom.equalToSuperview().inset(14) + } + emptyImageLabel.snp.makeConstraints { make in + make.edges.equalTo(imageStack) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with lead: ReferralLeadEntity) { + phoneValueLabel.text = lead.displayPhone.isEmpty ? "—" : lead.displayPhone + timeLabel.text = lead.createdAt + salerRow.setValue(lead.displaySalerName.isEmpty ? "—" : lead.displaySalerName) + remarkLabel.text = lead.remark.isEmpty ? "暂无备注" : lead.remark + imageURLs = lead.displayImages + imageStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + if imageURLs.isEmpty { + emptyImageLabel.isHidden = false + imageStack.isHidden = true + } else { + emptyImageLabel.isHidden = true + imageStack.isHidden = false + imageURLs.prefix(3).enumerated().forEach { index, url in + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = AppRadius.sm + imageView.backgroundColor = UIColor(hex: 0xF4F4F4) + imageView.isUserInteractionEnabled = true + imageView.loadRemoteImage(urlString: url) + imageView.tag = index + imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:)))) + imageStack.addArrangedSubview(imageView) + } + let missing = 3 - imageStack.arrangedSubviews.count + if missing > 0 { + (0.. Void)? + var onEditCommission: (() -> Void)? + var onCall: (() -> Void)? + + private let cardView = UIView() + private let nameLabel = UILabel() + private let editRemarkButton = UIButton(type: .system) + private let phoneLabel = UILabel() + private let callButton = UIButton(type: .system) + private let bindTimeRow = CooperationInfoRowView(label: "绑定时间") + private let commissionRow = CooperationInfoRowView(label: "分成比例") + private let editCommissionButton = UIButton(type: .system) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.lg + + nameLabel.font = .systemFont(ofSize: 16, weight: .bold) + nameLabel.textColor = AppColor.textPrimary + + editRemarkButton.setTitle("修改备注", for: .normal) + editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13) + editRemarkButton.setTitleColor(AppColor.primary, for: .normal) + editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside) + + phoneLabel.font = .systemFont(ofSize: 13) + phoneLabel.textColor = AppColor.textSecondary + + callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) + callButton.tintColor = AppColor.primary + callButton.backgroundColor = AppColor.primaryLight + callButton.layer.cornerRadius = 15 + callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) + + editCommissionButton.setTitle("修改", for: .normal) + editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13) + editCommissionButton.setTitleColor(AppColor.primary, for: .normal) + editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside) + + contentView.addSubview(cardView) + cardView.addSubview(nameLabel) + cardView.addSubview(editRemarkButton) + cardView.addSubview(phoneLabel) + cardView.addSubview(callButton) + cardView.addSubview(bindTimeRow) + cardView.addSubview(commissionRow) + cardView.addSubview(editCommissionButton) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)) + } + nameLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(14) + } + editRemarkButton.snp.makeConstraints { make in + make.centerY.equalTo(nameLabel) + make.leading.equalTo(nameLabel.snp.trailing).offset(AppSpacing.xs) + } + phoneLabel.snp.makeConstraints { make in + make.top.equalTo(nameLabel.snp.bottom).offset(10) + make.leading.equalToSuperview().offset(14) + } + callButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(14) + make.centerY.equalTo(phoneLabel) + make.size.equalTo(30) + } + bindTimeRow.snp.makeConstraints { make in + make.top.equalTo(phoneLabel.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview().inset(14) + } + commissionRow.snp.makeConstraints { make in + make.top.equalTo(bindTimeRow.snp.bottom).offset(10) + make.leading.equalToSuperview().offset(14) + make.bottom.equalToSuperview().inset(14) + } + editCommissionButton.snp.makeConstraints { make in + make.centerY.equalTo(commissionRow) + make.leading.equalTo(commissionRow.snp.trailing).offset(AppSpacing.xs) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with acquirer: CooperativeSalerEntity) { + nameLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName + phoneLabel.text = "手机号 \(CooperationOrderPhoneMask.mask(acquirer.displayPhone))" + bindTimeRow.setValue(acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime) + commissionRow.setValue(acquirer.displayCommissionRate) + } + + @objc private func editRemarkTapped() { onEditRemark?() } + @objc private func editCommissionTapped() { onEditCommission?() } + @objc private func callTapped() { onCall?() } +} + +/// 标签-值行。 +final class CooperationInfoRowView: UIView { + private let titleLabel = UILabel() + private let valueLabel = UILabel() + + init(label: String) { + super.init(frame: .zero) + titleLabel.text = label + titleLabel.font = .systemFont(ofSize: 13) + titleLabel.textColor = AppColor.textTertiary + valueLabel.font = .systemFont(ofSize: 13) + valueLabel.textColor = AppColor.textSecondary + valueLabel.numberOfLines = 0 + + addSubview(titleLabel) + addSubview(valueLabel) + titleLabel.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + make.width.equalTo(72) + } + valueLabel.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(12) + make.trailing.top.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setValue(_ value: String) { + valueLabel.text = value + } +} + +enum CooperationOrderPhoneMask { + static func mask(_ phone: String) -> String { + let digits = phone.filter(\.isNumber) + guard digits.count >= 7 else { return phone } + return "\(digits.prefix(3))****\(digits.suffix(4))" + } +} diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index d9ce76e..5954e0c 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -89,6 +89,11 @@ final class HomeViewController: BaseViewController { ) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if !hasInitialized { @@ -361,18 +366,17 @@ final class HomeViewController: BaseViewController { private func presentScenicDialog() { let alert = UIAlertController( - title: "请选择景区", - message: "使用首页功能前需要先选择当前景区", + title: "提示", + message: "您还没有选定入驻景区,请先入驻", preferredStyle: .alert ) - alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in - self?.viewModel.hideScenicDialog() - self?.activeDialog = nil + alert.isModalInPresentation = true + alert.addAction(UIAlertAction(title: "关闭app", style: .default) { _ in + UIApplication.shared.perform(#selector(NSXPCConnection.suspend)) }) - alert.addAction(UIAlertAction(title: "去选择", style: .default) { [weak self] _ in - self?.viewModel.hideScenicDialog() + alert.addAction(UIAlertAction(title: "去入驻", style: .default) { [weak self] _ in self?.activeDialog = nil - self?.presentScenicSelection() + self?.pushScenicSelection() }) present(alert, animated: true) } @@ -440,24 +444,24 @@ final class HomeViewController: BaseViewController { } private func presentScenicSelection() { - let scenicList = AppStore.shared.roleScenicList() - guard !scenicList.isEmpty else { - showToast("暂无可选景区") - return - } - let controller = ScenicSelectionViewController(scenicList: scenicList) - controller.onSelected = { [weak self] in + pushScenicSelection() + } + + private func pushScenicSelection() { + navigationController?.setNavigationBarHidden(false, animated: true) + let controller = ScenicSelectionViewController() + controller.onSelectionComplete = { [weak self] in guard let self else { return } Task { - await self.viewModel.reloadIfNeeded(api: self.homeAPI) + self.viewModel.refreshLocalDisplayState() + self.viewModel.rebuildCommonMenus() await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI) self.applyViewModel() - await self.evaluateDialogsWithDelay() + await self.viewModel.evaluateDialogs(api: self.homeAPI) + await MainActor.run { self.presentDialogsIfNeeded() } } } - let nav = UINavigationController(rootViewController: controller) - nav.modalPresentationStyle = .pageSheet - present(nav, animated: true) + navigationController?.pushViewController(controller, animated: true) } private func startCountdownTimer() { diff --git a/suixinkan/UI/Home/PermissionApplyStatusViewController.swift b/suixinkan/UI/Home/PermissionApplyStatusViewController.swift new file mode 100644 index 0000000..f39cb58 --- /dev/null +++ b/suixinkan/UI/Home/PermissionApplyStatusViewController.swift @@ -0,0 +1,194 @@ +// +// PermissionApplyStatusViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 权限申请审核状态页,对齐 Android `PermissionApplyStatusScreen`。 +final class PermissionApplyStatusViewController: BaseViewController { + + private let viewModel: PermissionApplyStatusViewModel + private let homeAPI: HomeAPI + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let bannerView = PermissionApplyBannerView() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + + init( + applyCode: String, + viewModel: PermissionApplyStatusViewModel? = nil, + homeAPI: HomeAPI = NetworkServices.shared.homeAPI + ) { + self.viewModel = viewModel ?? PermissionApplyStatusViewModel(applyCode: applyCode) + self.homeAPI = homeAPI + 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() { + contentStack.axis = .vertical + contentStack.spacing = 12 + scrollView.addSubview(contentStack) + view.addSubview(scrollView) + view.addSubview(loadingIndicator) + + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)) + make.width.equalTo(scrollView).offset(-32) + } + scrollView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onNavigateToEdit = { [weak self] pending in + Task { @MainActor in self?.navigateToEdit(pending: pending) } + } + bannerView.onApplyNewScenic = { [weak self] in + self?.showToast("功能开发中") + } + Task { await viewModel.loadPendingData(api: homeAPI) } + } + + private func applyViewModel() { + if viewModel.isLoading { + loadingIndicator.startAnimating() + scrollView.isHidden = true + return + } + loadingIndicator.stopAnimating() + scrollView.isHidden = false + contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + contentStack.addArrangedSubview(bannerView) + + navigationItem.rightBarButtonItem = nil + if let pending = viewModel.pendingData, pending.status == PermissionApplyPendingStatus.rejected { + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "去编辑", + style: .plain, + target: self, + action: #selector(editTapped) + ) + navigationItem.rightBarButtonItem?.tintColor = AppColor.primary + } + + guard let pending = viewModel.pendingData else { return } + + contentStack.addArrangedSubview(makeStatusCard(pending: pending)) + contentStack.addArrangedSubview(makeSectionCard(title: "新增角色", chips: [pending.roleName])) + if !pending.scenicList.isEmpty { + contentStack.addArrangedSubview( + makeSectionCard(title: "新增景区", chips: pending.scenicList.map(\.name)) + ) + } + } + + private func makeStatusCard(pending: RoleApplyPendingResponse) -> UIView { + let card = UIView() + card.backgroundColor = AppColor.warningBackground + card.layer.cornerRadius = 12 + card.layer.borderWidth = 1 + card.layer.borderColor = AppColor.warning.cgColor + + let statusLabel = UILabel() + statusLabel.text = pending.statusLabel + statusLabel.font = .systemFont(ofSize: 18, weight: .medium) + statusLabel.textColor = AppColor.warning + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 8 + stack.addArrangedSubview(statusLabel) + [ + ("提交时间:", pending.createdAt), + ("审核时间:", pending.auditedAt ?? "--"), + ("审核人:", pending.auditedBy ?? "--"), + ("审核备注:", pending.auditNote.isEmpty ? "--" : pending.auditNote), + ].forEach { label, value in + stack.addArrangedSubview(makeInfoRow(label: label, value: value)) + } + + card.addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + return card + } + + private func makeSectionCard(title: String, chips: [String]) -> UIView { + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 12 + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + + let chipStack = UIStackView() + chipStack.axis = .horizontal + chipStack.spacing = 8 + chips.forEach { name in + chipStack.addArrangedSubview(PermissionTagChip(title: name, showsRemove: false)) + } + + card.addSubview(titleLabel) + card.addSubview(chipStack) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + chipStack.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.trailing.bottom.equalToSuperview().inset(16) + } + return card + } + + private func makeInfoRow(label: String, value: String) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.distribution = .equalSpacing + let left = UILabel() + left.text = label + left.font = .systemFont(ofSize: 14) + left.textColor = AppColor.textSecondary + let right = UILabel() + right.text = value.isEmpty ? "--" : value + right.font = .systemFont(ofSize: 14) + right.textColor = AppColor.textSecondary + right.textAlignment = .right + row.addArrangedSubview(left) + row.addArrangedSubview(right) + return row + } + + @objc private func editTapped() { + viewModel.navigateToEditIfRejected() + } + + private func navigateToEdit(pending: RoleApplyPendingResponse) { + var controllers = navigationController?.viewControllers ?? [] + controllers.removeAll { $0 is PermissionApplyStatusViewController } + let applyVC = PermissionApplyViewController(pendingData: pending) + controllers.append(applyVC) + navigationController?.setViewControllers(controllers, animated: true) + } +} diff --git a/suixinkan/UI/Home/PermissionApplyViewController.swift b/suixinkan/UI/Home/PermissionApplyViewController.swift new file mode 100644 index 0000000..a4dc6c9 --- /dev/null +++ b/suixinkan/UI/Home/PermissionApplyViewController.swift @@ -0,0 +1,208 @@ +// +// PermissionApplyViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 权限申请页,对齐 Android `PermissionApplyScreen`。 +final class PermissionApplyViewController: BaseViewController { + + private let viewModel: PermissionApplyViewModel + private let homeAPI: HomeAPI + private let pendingData: RoleApplyPendingResponse? + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let bannerView = PermissionApplyBannerView() + private let roleSelector = PermissionFormSelectorRow(title: "角色信息", subtitle: "单选 · 请选择您需要申请权限的角色", placeholder: "请选择角色") + private let existingScenicTags = PermissionSelectedTagsView() + private let scenicSelector = PermissionFormSelectorRow(title: "新增景区", subtitle: "多选 · 请选择需要新增权限的景区", placeholder: "请选择景区") + private let selectedScenicTags = PermissionSelectedTagsView() + private let submitButton = PermissionSubmitButton() + + init( + pendingData: RoleApplyPendingResponse? = nil, + viewModel: PermissionApplyViewModel = PermissionApplyViewModel(), + homeAPI: HomeAPI = NetworkServices.shared.homeAPI + ) { + self.pendingData = pendingData + self.viewModel = viewModel + self.homeAPI = homeAPI + 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() { + contentStack.axis = .vertical + contentStack.spacing = 16 + + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 8 + let cardStack = UIStackView(arrangedSubviews: [roleSelector, existingScenicTags, scenicSelector, selectedScenicTags]) + cardStack.axis = .vertical + cardStack.spacing = 20 + card.addSubview(cardStack) + cardStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + + contentStack.addArrangedSubview(bannerView) + contentStack.addArrangedSubview(card) + scrollView.addSubview(contentStack) + view.addSubview(scrollView) + view.addSubview(submitButton) + + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)) + make.width.equalTo(scrollView).offset(-32) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(submitButton.snp.top).offset(-12) + } + submitButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onSubmitSuccess = { [weak self] applyCode in + Task { @MainActor in self?.handleSubmitSuccess(applyCode: applyCode) } + } + + bannerView.onApplyNewScenic = { [weak self] in + self?.showToast("功能开发中") + } + roleSelector.onTap = { [weak self] in self?.presentRolePicker() } + scenicSelector.onTap = { [weak self] in + guard let self else { return } + self.viewModel.showScenicPickerSheet() + if self.viewModel.availableScenics.isEmpty { + Task { await self.viewModel.loadAvailableScenics(api: self.homeAPI) } + } + self.presentScenicPickerIfNeeded() + } + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + + if let pendingData { + viewModel.initWithPendingData(pendingData) + Task { await viewModel.loadAvailableScenics(api: homeAPI) } + } + applyViewModel() + } + + private func applyViewModel() { + if viewModel.selectedRoleName.isEmpty { + roleSelector.setValue("请选择角色", isPlaceholder: true) + existingScenicTags.isHidden = true + } else { + roleSelector.setValue(viewModel.selectedRoleName, isPlaceholder: false) + if let roleId = viewModel.selectedRoleId { + let names = viewModel.existingScenics(for: roleId).map(\.name) + existingScenicTags.apply(title: "已开通景区", names: names, onRemove: nil) + } + } + + if viewModel.selectedScenicNames.isEmpty { + scenicSelector.setValue("请选择景区", isPlaceholder: true) + selectedScenicTags.isHidden = true + } else { + scenicSelector.setValue("已选 \(viewModel.selectedScenicNames.count) 个景区", isPlaceholder: false) + selectedScenicTags.apply( + title: "待申请景区", + names: viewModel.selectedScenicNames + ) { [weak self] index in + guard let self else { return } + let id = self.viewModel.selectedScenicIds[index] + let name = self.viewModel.selectedScenicNames[index] + self.viewModel.removeSelectedScenic(id: id, name: name) + } + } + + submitButton.setSubmitting(viewModel.isSubmitting) + presentScenicPickerIfNeeded() + presentRolePickerIfNeeded() + } + + private func presentRolePickerIfNeeded() { + guard viewModel.showRolePicker else { return } + viewModel.hideRolePickerSheet() + presentRolePicker() + } + + private func presentRolePicker() { + let alert = UIAlertController(title: "选择角色", message: nil, preferredStyle: .actionSheet) + viewModel.availableRoles.forEach { role in + alert.addAction(UIAlertAction(title: role.name, style: .default) { [weak self] _ in + self?.viewModel.selectRole(role) + Task { await self?.viewModel.loadAvailableScenics(api: self?.homeAPI) } + }) + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(alert, animated: true) + } + + private func presentScenicPickerIfNeeded() { + guard viewModel.showScenicPicker else { return } + viewModel.hideScenicPickerSheet() + presentScenicPicker() + } + + private func presentScenicPicker() { + guard viewModel.selectedRoleId != nil else { + showToast("请先选择角色") + return + } + if viewModel.availableScenics.isEmpty { + Task { + await viewModel.loadAvailableScenics(api: homeAPI) + await MainActor.run { self.presentScenicPicker() } + } + return + } + + let alert = UIAlertController(title: "选择景区", message: nil, preferredStyle: .actionSheet) + viewModel.availableScenics.filter { !$0.isDisabled }.forEach { scenic in + let prefix = scenic.isSelected ? "✓ " : "" + alert.addAction(UIAlertAction(title: "\(prefix)\(scenic.name)", style: .default) { [weak self] _ in + self?.viewModel.toggleScenic(scenic) + }) + } + alert.addAction(UIAlertAction(title: "完成", style: .cancel)) + present(alert, animated: true) + } + + @objc private func submitTapped() { + Task { await viewModel.submit(api: homeAPI) } + } + + private func handleSubmitSuccess(applyCode: String) { + guard !applyCode.isEmpty else { + navigationController?.popViewController(animated: true) + return + } + var controllers = navigationController?.viewControllers ?? [] + controllers.removeAll { $0 === self } + let statusVC = PermissionApplyStatusViewController(applyCode: applyCode) + controllers.append(statusVC) + navigationController?.setViewControllers(controllers, animated: true) + } +} diff --git a/suixinkan/UI/Home/ScenicSelectionViewController.swift b/suixinkan/UI/Home/ScenicSelectionViewController.swift index 9c133db..6cc4c59 100644 --- a/suixinkan/UI/Home/ScenicSelectionViewController.swift +++ b/suixinkan/UI/Home/ScenicSelectionViewController.swift @@ -6,16 +6,23 @@ import SnapKit import UIKit -/// 景区选择页,使用 role-permission 返回的景区列表。 -final class ScenicSelectionViewController: BaseViewController { +/// 景区选择页,对齐 Android `ScenicSelectionScreen`。 +final class ScenicSelectionViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate { - var onSelected: (() -> Void)? + var onSelectionComplete: (() -> Void)? - private let scenicList: [ScenicInfo] - private let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let viewModel: ScenicSelectionViewModel + private let homeAPI: HomeAPI - init(scenicList: [ScenicInfo]) { - self.scenicList = scenicList + private let bannerView = ScenicPermissionBannerView() + private let searchBar = ScenicSelectionSearchBar() + private let locationBar = ScenicCurrentLocationBar() + private let tableView = UITableView(frame: .zero, style: .plain) + private let emptyLabel = UILabel() + + init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) { + self.viewModel = viewModel + self.homeAPI = homeAPI super.init(nibName: nil, bundle: nil) } @@ -25,61 +32,122 @@ final class ScenicSelectionViewController: BaseViewController { } override func setupNavigationBar() { - title = "选择景区" - navigationItem.leftBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .close, - target: self, - action: #selector(closeTapped) - ) + title = "景区选择" + navigationController?.setNavigationBarHidden(false, animated: false) } override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF7FAFF) + view.backgroundColor = .white + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none tableView.dataSource = self tableView.delegate = self - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") - view.addSubview(tableView) - } + tableView.register(ScenicSpotCell.self, forCellReuseIdentifier: ScenicSpotCell.reuseIdentifier) + tableView.keyboardDismissMode = .onDrag - override func setupConstraints() { + emptyLabel.text = "暂无景区" + emptyLabel.font = .systemFont(ofSize: 14) + emptyLabel.textColor = AppColor.textTertiary + emptyLabel.textAlignment = .center + emptyLabel.isHidden = true + + let headerStack = UIStackView(arrangedSubviews: [bannerView, searchBar, locationBar]) + headerStack.axis = .vertical + headerStack.spacing = 16 + headerStack.isLayoutMarginsRelativeArrangement = true + headerStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16) + + view.addSubview(headerStack) + view.addSubview(tableView) + view.addSubview(emptyLabel) + + headerStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + } tableView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) + make.top.equalTo(headerStack.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) } } - @objc private func closeTapped() { - dismiss(animated: true) - } -} + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onSelectionComplete = { [weak self] in + Task { @MainActor in + self?.onSelectionComplete?() + self?.navigationController?.popViewController(animated: true) + } + } + viewModel.onNavigateApply = { [weak self] in + Task { @MainActor in self?.pushPermissionApply() } + } + viewModel.onNavigateApplyStatus = { [weak self] applyCode in + Task { @MainActor in self?.pushPermissionApplyStatus(applyCode: applyCode) } + } + + bannerView.onApplyNow = { [weak self] in + guard let self else { return } + Task { await self.viewModel.onApplyNow(api: self.homeAPI) } + } + searchBar.onSearchTextChange = { [weak self] query in + self?.viewModel.onSearchQueryChange(query) + } + locationBar.onRelocate = { [weak self] in + guard let self else { return } + Task { await self.viewModel.startLocation() } + } + + viewModel.loadFromLocal() + Task { await viewModel.startLocation() } + } + + private func applyViewModel() { + locationBar.apply(locationText: viewModel.currentLocationText) + emptyLabel.isHidden = !viewModel.spots.isEmpty + tableView.reloadData() + if viewModel.isLoading { + showLoading() + } else { + hideLoading() + } + } + + private func pushPermissionApply(pendingData: RoleApplyPendingResponse? = nil) { + let controller = PermissionApplyViewController(pendingData: pendingData) + navigationController?.pushViewController(controller, animated: true) + } + + private func pushPermissionApplyStatus(applyCode: String) { + let controller = PermissionApplyStatusViewController(applyCode: applyCode) + navigationController?.pushViewController(controller, animated: true) + } + + // MARK: - UITableView -extension ScenicSelectionViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - scenicList.count + viewModel.spots.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - var config = cell.defaultContentConfiguration() - config.text = scenicList[indexPath.row].name - cell.contentConfiguration = config - cell.accessoryType = scenicList[indexPath.row].id == AppStore.shared.currentScenicId ? .checkmark : .none + let cell = tableView.dequeueReusableCell( + withIdentifier: ScenicSpotCell.reuseIdentifier, + for: indexPath + ) as! ScenicSpotCell + let spot = viewModel.spots[indexPath.row] + cell.apply(spot) + cell.onTap = { [weak self] in + self?.viewModel.selectSpot(spot) + } return cell } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - let scenic = scenicList[indexPath.row] - AppStore.shared.currentScenicId = scenic.id - AppStore.shared.currentScenicName = scenic.name - NotificationCenter.default.post( - name: NotificationName.scenicDidChange, - object: nil, - userInfo: [ - NotificationUserInfoKey.scenicId: scenic.id, - NotificationUserInfoKey.scenicName: scenic.name, - ] - ) - onSelected?() - dismiss(animated: true) - } } diff --git a/suixinkan/UI/Home/Views/PermissionApplyViews.swift b/suixinkan/UI/Home/Views/PermissionApplyViews.swift new file mode 100644 index 0000000..bda37b8 --- /dev/null +++ b/suixinkan/UI/Home/Views/PermissionApplyViews.swift @@ -0,0 +1,252 @@ +// +// PermissionApplyViews.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 权限申请页橙色横幅(新景区申请入口暂 toast)。 +final class PermissionApplyBannerView: UIView { + + var onApplyNewScenic: (() -> Void)? + + private let titleLabel = UILabel() + private let applyButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = AppColor.warningBackground + + titleLabel.text = "希望开通全新景区?" + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = AppColor.warning + + applyButton.setTitle("新景区申请 ›", for: .normal) + applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + applyButton.setTitleColor(AppColor.warning, for: .normal) + applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside) + + addSubview(titleLabel) + addSubview(applyButton) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + } + applyButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-16) + make.centerY.equalToSuperview() + } + snp.makeConstraints { make in + make.height.equalTo(44) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func applyTapped() { + onApplyNewScenic?() + } +} + +/// 表单字段选择行。 +final class PermissionFormSelectorRow: UIView { + + var onTap: (() -> Void)? + + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let valueLabel = UILabel() + private let chevron = UIImageView(image: UIImage(systemName: "chevron.down")) + + init(title: String, subtitle: String, placeholder: String) { + super.init(frame: .zero) + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + + subtitleLabel.text = subtitle + subtitleLabel.font = .systemFont(ofSize: 12) + subtitleLabel.textColor = AppColor.textTertiary + + valueLabel.text = placeholder + valueLabel.font = .systemFont(ofSize: 14) + valueLabel.textColor = AppColor.textTertiary + + chevron.tintColor = AppColor.textTertiary + chevron.contentMode = .scaleAspectFit + + let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped)) + addGestureRecognizer(tap) + + addSubview(titleLabel) + addSubview(subtitleLabel) + addSubview(valueLabel) + addSubview(chevron) + + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview() + } + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.equalToSuperview() + } + valueLabel.snp.makeConstraints { make in + make.top.equalTo(subtitleLabel.snp.bottom).offset(12) + make.leading.bottom.equalToSuperview() + make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8) + } + chevron.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.centerY.equalTo(valueLabel) + make.size.equalTo(14) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setValue(_ text: String, isPlaceholder: Bool) { + valueLabel.text = text + valueLabel.textColor = isPlaceholder ? AppColor.textTertiary : AppColor.textPrimary + } + + @objc private func rowTapped() { + onTap?() + } +} + +/// 已选景区标签容器。 +final class PermissionSelectedTagsView: UIView { + + private let titleLabel = UILabel() + private let stackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + stackView.axis = .vertical + stackView.spacing = 8 + addSubview(titleLabel) + addSubview(stackView) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + stackView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(title: String, names: [String], onRemove: ((Int) -> Void)? = nil) { + titleLabel.text = title + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + guard !names.isEmpty else { + isHidden = true + return + } + isHidden = false + let row = UIStackView() + row.axis = .horizontal + row.spacing = 8 + row.alignment = .leading + names.enumerated().forEach { index, name in + let chip = PermissionTagChip(title: name) + if let onRemove { + chip.onRemove = { onRemove(index) } + } + row.addArrangedSubview(chip) + } + stackView.addArrangedSubview(row) + } +} + +/// 标签 Chip。 +final class PermissionTagChip: UIView { + + var onRemove: (() -> Void)? + + private let label = UILabel() + private let removeButton = UIButton(type: .system) + + init(title: String, showsRemove: Bool = true) { + super.init(frame: .zero) + layer.cornerRadius = 4 + layer.borderWidth = 1 + layer.borderColor = AppColor.primary.cgColor + backgroundColor = .white + + label.text = title + label.font = .systemFont(ofSize: 14) + label.textColor = AppColor.primary + + removeButton.setImage(UIImage(systemName: "xmark"), for: .normal) + removeButton.tintColor = AppColor.primary + removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) + removeButton.isHidden = !showsRemove + + addSubview(label) + addSubview(removeButton) + label.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.top.bottom.equalToSuperview().inset(8) + if showsRemove { + make.trailing.equalTo(removeButton.snp.leading).offset(-4) + } else { + make.trailing.equalToSuperview().offset(-12) + } + } + removeButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-8) + make.centerY.equalToSuperview() + make.size.equalTo(20) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func removeTapped() { + onRemove?() + } +} + +/// 底部主按钮。 +final class PermissionSubmitButton: UIButton { + + override init(frame: CGRect) { + super.init(frame: frame) + setTitle("提交申请", for: .normal) + setTitleColor(.white, for: .normal) + titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + backgroundColor = AppColor.primary + layer.cornerRadius = 8 + snp.makeConstraints { make in + make.height.equalTo(48) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setSubmitting(_ submitting: Bool) { + isEnabled = !submitting + alpha = submitting ? 0.6 : 1 + setTitle(submitting ? "提交中..." : "提交申请", for: .normal) + } +} diff --git a/suixinkan/UI/Home/Views/ScenicSelectionViews.swift b/suixinkan/UI/Home/Views/ScenicSelectionViews.swift new file mode 100644 index 0000000..1fd4363 --- /dev/null +++ b/suixinkan/UI/Home/Views/ScenicSelectionViews.swift @@ -0,0 +1,300 @@ +// +// ScenicSelectionViews.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// 景区选择页橙色权限申请横幅。 +final class ScenicPermissionBannerView: UIView { + + var onApplyNow: (() -> Void)? + + private let titleLabel = UILabel() + private let applyButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = AppColor.warningBackground + + titleLabel.text = "希望开通更多景区权限" + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = AppColor.warning + titleLabel.numberOfLines = 2 + + applyButton.setTitle("立即申请 ›", for: .normal) + applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) + applyButton.setTitleColor(AppColor.warning, for: .normal) + applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside) + + addSubview(titleLabel) + addSubview(applyButton) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(applyButton.snp.leading).offset(-8) + } + applyButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-16) + make.centerY.equalToSuperview() + } + snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(44) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func applyTapped() { + onApplyNow?() + } +} + +/// 景区搜索栏。 +final class ScenicSelectionSearchBar: UIView, UITextFieldDelegate { + + var onSearchTextChange: ((String) -> Void)? + + let textField = UITextField() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hex: 0xF2F4F8) + layer.cornerRadius = 12 + clipsToBounds = true + + let icon = UIImageView(image: UIImage(systemName: "magnifyingglass")) + icon.tintColor = UIColor(hex: 0xB3B8C2) + icon.contentMode = .scaleAspectFit + + textField.placeholder = "搜索景区" + textField.font = .systemFont(ofSize: 14) + textField.textColor = AppColor.textPrimary + textField.returnKeyType = .search + textField.delegate = self + textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) + + addSubview(icon) + addSubview(textField) + icon.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.size.equalTo(18) + } + textField.snp.makeConstraints { make in + make.leading.equalTo(icon.snp.trailing).offset(8) + make.trailing.equalToSuperview().offset(-12) + make.top.bottom.equalToSuperview().inset(10) + } + snp.makeConstraints { make in + make.height.equalTo(44) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func textChanged() { + onSearchTextChange?(textField.text ?? "") + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} + +/// 当前定位栏。 +final class ScenicCurrentLocationBar: UIView { + + var onRelocate: (() -> Void)? + + private let iconView = UIImageView(image: UIImage(systemName: "location.fill")) + private let locationLabel = UILabel() + private let relocateButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + + locationLabel.font = .systemFont(ofSize: 14) + locationLabel.textColor = AppColor.textPrimary + locationLabel.numberOfLines = 2 + + relocateButton.setTitle("重新定位", for: .normal) + relocateButton.titleLabel?.font = .systemFont(ofSize: 12) + relocateButton.setTitleColor(AppColor.primary, for: .normal) + relocateButton.backgroundColor = AppColor.primaryLight + relocateButton.layer.cornerRadius = 4 + relocateButton.contentEdgeInsets = UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8) + relocateButton.addTarget(self, action: #selector(relocateTapped), for: .touchUpInside) + relocateButton.setContentHuggingPriority(.required, for: .horizontal) + relocateButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + addSubview(iconView) + addSubview(locationLabel) + addSubview(relocateButton) + + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview() + make.centerY.equalToSuperview() + make.size.equalTo(16) + } + locationLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(4) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(relocateButton.snp.leading).offset(-12) + } + relocateButton.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.centerY.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(locationText: String) { + locationLabel.text = locationText + } + + @objc private func relocateTapped() { + onRelocate?() + } +} + +/// 景区卡片 Cell。 +final class ScenicSpotCell: UITableViewCell { + + static let reuseIdentifier = "ScenicSpotCell" + + var onTap: (() -> Void)? + + private let cardView = UIView() + private let coverImageView = UIImageView() + private let nameLabel = UILabel() + private let distanceLabel = UILabel() + private let addressLabel = UILabel() + private let statusTag = UILabel() + private let selectedBorderColor = UIColor(hex: 0xE74C3C) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + cardView.backgroundColor = AppColor.pageBackgroundSoft + cardView.layer.cornerRadius = 8 + cardView.layer.borderWidth = 2 + cardView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor + + coverImageView.contentMode = .scaleAspectFill + coverImageView.clipsToBounds = true + coverImageView.layer.cornerRadius = 6 + coverImageView.backgroundColor = UIColor(hex: 0xF0F0F0) + + nameLabel.font = .systemFont(ofSize: 16, weight: .bold) + nameLabel.textColor = AppColor.textPrimary + + distanceLabel.font = .systemFont(ofSize: 12) + distanceLabel.textColor = AppColor.textSecondary + + addressLabel.font = .systemFont(ofSize: 12) + addressLabel.textColor = AppColor.textTertiary + addressLabel.numberOfLines = 2 + + statusTag.font = .systemFont(ofSize: 11, weight: .medium) + statusTag.textColor = .white + statusTag.textAlignment = .center + statusTag.layer.cornerRadius = 4 + statusTag.clipsToBounds = true + statusTag.isHidden = true + + contentView.addSubview(cardView) + cardView.addSubview(coverImageView) + cardView.addSubview(nameLabel) + cardView.addSubview(statusTag) + cardView.addSubview(distanceLabel) + cardView.addSubview(addressLabel) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 12, right: 16)) + } + coverImageView.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(12) + make.size.equalTo(80) + } + nameLabel.snp.makeConstraints { make in + make.leading.equalTo(coverImageView.snp.trailing).offset(12) + make.top.equalToSuperview().offset(12) + make.trailing.lessThanOrEqualTo(statusTag.snp.leading).offset(-8) + } + statusTag.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-12) + make.centerY.equalTo(nameLabel) + make.height.equalTo(20) + } + distanceLabel.snp.makeConstraints { make in + make.leading.equalTo(nameLabel) + make.top.equalTo(nameLabel.snp.bottom).offset(6) + } + addressLabel.snp.makeConstraints { make in + make.leading.equalTo(nameLabel) + make.trailing.equalToSuperview().offset(-12) + make.top.equalTo(distanceLabel.snp.bottom).offset(4) + make.bottom.lessThanOrEqualToSuperview().offset(-12) + } + + let tap = UITapGestureRecognizer(target: self, action: #selector(cardTapped)) + cardView.addGestureRecognizer(tap) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(_ item: ScenicSpotDisplayItem) { + nameLabel.text = item.name + distanceLabel.text = "\(item.distanceText)km" + addressLabel.text = item.address + + if let url = URL(string: item.coverImg), !item.coverImg.isEmpty { + coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) + } else { + coverImageView.image = UIImage(systemName: "photo") + coverImageView.tintColor = AppColor.textTertiary + } + + if let label = item.statusLabel { + statusTag.isHidden = false + statusTag.text = " \(label) " + switch item.openStatus { + case .nearest, .closed: + statusTag.backgroundColor = selectedBorderColor + case .none: + statusTag.backgroundColor = AppColor.textTertiary + } + } else { + statusTag.isHidden = true + } + + cardView.layer.borderColor = (item.isSelected ? selectedBorderColor : UIColor(hex: 0xF4F4F4)).cgColor + } + + @objc private func cardTapped() { + onTap?() + } +} diff --git a/suixinkan/UI/MainTab/MainTabBarController.swift b/suixinkan/UI/MainTab/MainTabBarController.swift index 6246304..4bc7808 100644 --- a/suixinkan/UI/MainTab/MainTabBarController.swift +++ b/suixinkan/UI/MainTab/MainTabBarController.swift @@ -136,6 +136,24 @@ final class MainTabBarController: UITabBarController { scanHandler.onShowMultiVerifySheet = { [weak self] spots, selected in self?.presentMultiVerifySheet(spots: spots, selected: selected) } + scanHandler.onNavigateBindAcquirer = { [weak self] saleUserId in + self?.presentBindAcquirerAfterScan(saleUserId: saleUserId) + } + } + + private func presentBindAcquirerAfterScan(saleUserId: Int) { + dismiss(animated: true) { [weak self] in + guard let self else { return } + let controller = BindAcquirerViewController(saleUserId: saleUserId) + self.topNavigationController()?.pushViewController(controller, animated: true) + } + } + + private func topNavigationController() -> UINavigationController? { + if let selected = selectedViewController as? UINavigationController { + return selected + } + return selectedViewController?.navigationController } private func showScanToast(_ message: String) { diff --git a/suixinkan/UI/Orders/OrderCardCell.swift b/suixinkan/UI/Orders/OrderCardCell.swift index 83cca19..7052427 100644 --- a/suixinkan/UI/Orders/OrderCardCell.swift +++ b/suixinkan/UI/Orders/OrderCardCell.swift @@ -20,6 +20,7 @@ struct OrderCardActions { var onRefinedChanged: ((Int) -> Void)? var onHistoricalPhoto: (() -> Void)? var onOrderSourceTap: (() -> Void)? + var onOrderSourceImageTap: (([String], Int) -> Void)? } /// 摄影师订单卡片,严格对齐 Android `OrderView`。 @@ -34,8 +35,7 @@ final class OrderCardCell: UITableViewCell { private let statusBadge = OrderStatusBadgeView() private let createdAtLabel = UILabel() private let typeChip = OrderTypeChipView() - private let orderSourceContainer = UIView() - private let orderSourceButton = UIButton(type: .system) + private let orderSourceEntryView = OrderSourceEntryView() private let actionRow = UIStackView() private let refundButton = OrderActionButton(title: "退款", style: .destructiveLight) private let verifyButton = OrderActionButton(title: "核销订单", style: .primaryFilled) @@ -79,7 +79,7 @@ final class OrderCardCell: UITableViewCell { hideWriteActions: Bool, actions: OrderCardActions, showOrderSource: Bool, - sourceText: String? + sourceSelection: OrderSourceSelection? ) { self.actions = actions orderNumberLabel.text = "订单号:\(order.orderNumber)" @@ -98,8 +98,10 @@ final class OrderCardCell: UITableViewCell { uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19 - orderSourceContainer.isHidden = !showOrderSource - orderSourceButton.setTitle(sourceText ?? "选择订单来源", for: .normal) + orderSourceEntryView.isHidden = !showOrderSource + if showOrderSource { + orderSourceEntryView.apply(selection: sourceSelection) + } let showEdit = (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19 editOptionView.isHidden = !showEdit @@ -149,17 +151,9 @@ final class OrderCardCell: UITableViewCell { headerRow.addArrangedSubview(orderNumberLabel) headerRow.addArrangedSubview(statusBadge) - orderSourceContainer.backgroundColor = OrderTokens.sourceBackground - orderSourceContainer.layer.cornerRadius = AppRadius.sm - orderSourceContainer.layer.borderWidth = 1 - orderSourceContainer.layer.borderColor = OrderTokens.sourceBorder.cgColor - orderSourceButton.setTitleColor(AppColor.primary, for: .normal) - orderSourceButton.titleLabel?.font = .app(.body) - orderSourceButton.contentHorizontalAlignment = .leading - orderSourceButton.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside) - orderSourceContainer.addSubview(orderSourceButton) - orderSourceButton.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppSpacing.sm) + orderSourceEntryView.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside) + orderSourceEntryView.onImageTap = { [weak self] images, index in + self?.actions.onOrderSourceImageTap?(images, index) } actionRow.axis = .horizontal @@ -211,7 +205,7 @@ final class OrderCardCell: UITableViewCell { headerRow, createdAtLabel, typeChip, - orderSourceContainer, + orderSourceEntryView, actionRow, uploadTaskButton, editOptionView, diff --git a/suixinkan/UI/Orders/OrderSourcePickerViewController.swift b/suixinkan/UI/Orders/OrderSourcePickerViewController.swift index a128683..de1606b 100644 --- a/suixinkan/UI/Orders/OrderSourcePickerViewController.swift +++ b/suixinkan/UI/Orders/OrderSourcePickerViewController.swift @@ -6,23 +6,58 @@ import SnapKit import UIKit -/// 订单来源选择器。 +/// 订单来源选择 Bottom Sheet,对齐 Android `OrderSourcePicker` 弹层。 final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { - var onPersonSelected: ((OrderSourcePerson) -> Void)? - var onLeadSelected: ((OrderSourceSelection) -> Void)? + private enum MenuLevel { + case person + case lead + } - private let people: [OrderSourcePerson] - private let leads: [OrderSourceLead] - private let selectedPerson: OrderSourcePerson? - private let tableView = UITableView(frame: .zero, style: .insetGrouped) - private var showingLeads = false + private let orderNumber: String + private let viewModel: OrderListViewModel + private let onPersonSelected: (OrderSourcePerson) -> Void + private let onLeadBound: (OrderSourceSelection) -> Void + private let onCallPhone: (String) -> Void + private let onPreviewImages: ([String], Int) -> Void - init(people: [OrderSourcePerson], leads: [OrderSourceLead], selectedPerson: OrderSourcePerson?) { - self.people = people - self.leads = leads - self.selectedPerson = selectedPerson - self.showingLeads = !leads.isEmpty + private var level: MenuLevel = .person + private var pendingPerson: OrderSourcePerson? + + private let headerStack = UIStackView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let leadHeaderRow = UIStackView() + private let backButton = UIButton(type: .system) + private let leadTitleLabel = UILabel() + private let leadPhoneButton = UIButton(type: .system) + private let leadCallButton = UIButton(type: .system) + private let tableView = UITableView(frame: .zero, style: .plain) + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let emptyLabel = UILabel() + + private var referralOrder: BoundReferralOrderEntity? + + init( + orderNumber: String, + viewModel: OrderListViewModel, + referralOrder: BoundReferralOrderEntity?, + onPersonSelected: @escaping (OrderSourcePerson) -> Void, + onLeadBound: @escaping (OrderSourceSelection) -> Void, + onCallPhone: @escaping (String) -> Void, + onPreviewImages: @escaping ([String], Int) -> Void + ) { + self.orderNumber = orderNumber + self.viewModel = viewModel + self.onPersonSelected = onPersonSelected + self.onLeadBound = onLeadBound + self.onCallPhone = onCallPhone + self.onPreviewImages = onPreviewImages + self.referralOrder = referralOrder + self.pendingPerson = viewModel.orderSourceSelection( + orderNumber: orderNumber, + referralOrder: referralOrder + )?.person super.init(nibName: nil, bundle: nil) } @@ -33,44 +68,210 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo 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() } + view.backgroundColor = .white + setupUI() + applyViewModel() } - @objc private func closeTapped() { - dismiss(animated: true) + func reloadFromViewModel() { + applyViewModel() + } + + private func setupUI() { + titleLabel.font = .systemFont(ofSize: 18, weight: .bold) + titleLabel.textColor = AppColor.text333 + titleLabel.text = "选择获客员" + + subtitleLabel.font = .systemFont(ofSize: 13) + subtitleLabel.textColor = AppColor.text666 + subtitleLabel.text = "选择人员后,再选择该人员创建的带客单" + subtitleLabel.numberOfLines = 0 + + backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) + backButton.tintColor = AppColor.text333 + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + + leadTitleLabel.font = .systemFont(ofSize: 18, weight: .bold) + leadTitleLabel.textColor = AppColor.text333 + leadTitleLabel.numberOfLines = 2 + + leadPhoneButton.titleLabel?.font = .systemFont(ofSize: 13) + leadPhoneButton.setTitleColor(AppColor.primary, for: .normal) + leadPhoneButton.contentHorizontalAlignment = .leading + leadPhoneButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside) + + leadCallButton.backgroundColor = OrderTokens.phoneIconBackground + leadCallButton.layer.cornerRadius = 16 + leadCallButton.tintColor = AppColor.primary + leadCallButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) + leadCallButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside) + + leadHeaderRow.axis = .horizontal + leadHeaderRow.alignment = .top + leadHeaderRow.spacing = 10 + leadHeaderRow.isHidden = true + let leadTextStack = UIStackView(arrangedSubviews: [leadTitleLabel, leadPhoneButton]) + leadTextStack.axis = .vertical + leadTextStack.spacing = 3 + leadHeaderRow.addArrangedSubview(backButton) + leadHeaderRow.addArrangedSubview(leadTextStack) + leadHeaderRow.addArrangedSubview(leadCallButton) + + headerStack.axis = .vertical + headerStack.spacing = 5 + headerStack.addArrangedSubview(titleLabel) + headerStack.addArrangedSubview(subtitleLabel) + headerStack.addArrangedSubview(leadHeaderRow) + + tableView.separatorStyle = .none + tableView.backgroundColor = .white + tableView.dataSource = self + tableView.delegate = self + tableView.register(OrderSourcePersonCell.self, forCellReuseIdentifier: OrderSourcePersonCell.reuseIdentifier) + tableView.register(OrderSourceLeadCell.self, forCellReuseIdentifier: OrderSourceLeadCell.reuseIdentifier) + + loadingIndicator.hidesWhenStopped = true + emptyLabel.font = .systemFont(ofSize: 14) + emptyLabel.textColor = AppColor.text666 + emptyLabel.textAlignment = .center + emptyLabel.numberOfLines = 0 + + view.addSubview(headerStack) + view.addSubview(tableView) + view.addSubview(loadingIndicator) + view.addSubview(emptyLabel) + + headerStack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + backButton.snp.makeConstraints { make in + make.width.height.equalTo(28) + } + leadCallButton.snp.makeConstraints { make in + make.width.height.equalTo(32) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(headerStack.snp.bottom).offset(14) + make.leading.trailing.bottom.equalToSuperview() + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) + make.leading.trailing.equalTo(tableView).inset(AppSpacing.lg) + } + } + + private func applyViewModel() { + let currentSelection = viewModel.orderSourceSelection( + orderNumber: orderNumber, + referralOrder: referralOrder + ) + titleLabel.isHidden = level == .lead + subtitleLabel.isHidden = level == .lead + leadHeaderRow.isHidden = level == .person + + if level == .lead, let person = pendingPerson { + leadTitleLabel.text = "\(person.name)创建的带客单" + let phone = person.phone.trimmingCharacters(in: .whitespacesAndNewlines) + leadPhoneButton.setTitle(phone, for: .normal) + leadPhoneButton.isHidden = phone.isEmpty + leadCallButton.isHidden = phone.isEmpty + } + + let isLoadingPeople = viewModel.isLoadingOrderSourcePeople && level == .person + let isLoadingLeads = viewModel.loadingOrderSourceLeadsFor != nil && level == .lead + if isLoadingPeople || isLoadingLeads { + loadingIndicator.startAnimating() + tableView.isHidden = true + emptyLabel.isHidden = true + } else { + loadingIndicator.stopAnimating() + tableView.isHidden = false + let isEmpty: Bool + switch level { + case .person: + isEmpty = viewModel.orderSourcePeople.isEmpty + case .lead: + isEmpty = leadsForPendingPerson.isEmpty + } + emptyLabel.isHidden = !isEmpty + emptyLabel.text = level == .person ? "暂无合作获客员" : "暂无可绑定带客单" + tableView.reloadData() + } + _ = currentSelection + } + + private var leadsForPendingPerson: [OrderSourceLead] { + guard let person = pendingPerson else { return [] } + return viewModel.orderSourceLeads[person.key] ?? [] + } + + @objc private func backTapped() { + level = .person + applyViewModel() + } + + @objc private func leadPhoneTapped() { + guard let phone = pendingPerson?.phone.trimmingCharacters(in: .whitespacesAndNewlines), !phone.isEmpty else { return } + onCallPhone(phone) } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - showingLeads ? leads.count : people.count + switch level { + case .person: + return viewModel.orderSourcePeople.count + case .lead: + return leadsForPendingPerson.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 + let currentSelection = viewModel.orderSourceSelection( + orderNumber: orderNumber, + referralOrder: referralOrder + ) + switch level { + case .person: + let cell = tableView.dequeueReusableCell( + withIdentifier: OrderSourcePersonCell.reuseIdentifier, + for: indexPath + ) as! OrderSourcePersonCell + let person = viewModel.orderSourcePeople[indexPath.row] + cell.configure( + person: person, + selected: currentSelection?.person.key == person.key + ) + cell.onCallPhone = onCallPhone + return cell + case .lead: + let cell = tableView.dequeueReusableCell( + withIdentifier: OrderSourceLeadCell.reuseIdentifier, + for: indexPath + ) as! OrderSourceLeadCell + let lead = leadsForPendingPerson[indexPath.row] + let selected = currentSelection?.person.key == pendingPerson?.key + && currentSelection?.lead.key == lead.key + cell.configure(lead: lead, selected: selected) + cell.onCallPhone = onCallPhone + cell.onImageTap = onPreviewImages + cell.onSelect = { [weak self] in + guard let self, let person = self.pendingPerson else { return } + let selection = OrderSourceSelection(person: person, lead: lead) + self.onLeadBound(selection) + } + return cell } - 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]) - } + guard level == .person else { return } + let person = viewModel.orderSourcePeople[indexPath.row] + pendingPerson = person + level = .lead + onPersonSelected(person) + applyViewModel() } } diff --git a/suixinkan/UI/Orders/OrdersViewController.swift b/suixinkan/UI/Orders/OrdersViewController.swift index 8812be1..fe1a1f4 100644 --- a/suixinkan/UI/Orders/OrdersViewController.swift +++ b/suixinkan/UI/Orders/OrdersViewController.swift @@ -20,7 +20,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT private var hasInitialized = false private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin } private var pendingOrderSourceNumber: String? - private var pendingOrderSourcePerson: OrderSourcePerson? + private weak var orderSourcePicker: OrderSourcePickerViewController? private var pendingGiftOrderNumber: String? private var isFetchingMultiVerifySpots = false private let locationProvider = OrderLocationProvider() @@ -51,11 +51,17 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT override func bindActions() { orderListViewModel.onStateChange = { [weak self] in - Task { @MainActor in self?.applyViewModel() } + Task { @MainActor in + self?.applyViewModel() + self?.orderSourcePicker?.reloadFromViewModel() + } } orderListViewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } + orderListViewModel.onNavigateBindAcquirer = { [weak self] saleUserId in + Task { @MainActor in self?.presentBindAcquirer(saleUserId: saleUserId) } + } depositViewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyViewModel() } } @@ -259,6 +265,13 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT present(nav, animated: true) } + private func presentBindAcquirer(saleUserId: Int) { + dismiss(animated: true) { [weak self] in + let controller = BindAcquirerViewController(saleUserId: saleUserId) + self?.navigationController?.pushViewController(controller, animated: true) + } + } + private func fetchMultiVerifySpotsIfNeeded() async { isFetchingMultiVerifySpots = true defer { isFetchingMultiVerifySpots = false } @@ -322,7 +335,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT hideWriteActions: orderListViewModel.isScenicAdminReadOnly, actions: makeOrderActions(for: order), showOrderSource: orderListViewModel.shouldShowOrderSource(for: order), - sourceText: source.map { "\($0.person.name) · \($0.lead.displayText)" } + sourceSelection: source ) listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: orderListViewModel.shouldLoadMore(at: indexPath.row)) return cell @@ -363,6 +376,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT actions.onOrderSourceTap = { [weak self] in self?.presentOrderSourcePicker(for: order) } + actions.onOrderSourceImageTap = { [weak self] images, index in + self?.presentImagePreview(images: images, startIndex: index) + } if let travel = order.photoTravel, travel.canGiftRetouch { actions.onSendGift = { [weak self] in guard let self else { return } @@ -451,23 +467,23 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT pendingOrderSourceNumber = order.orderNumber Task { await orderListViewModel.onOrderSourceSheetOpen(api: orderAPI) - let controller = OrderSourcePickerViewController( - people: orderListViewModel.orderSourcePeople, - leads: [], - selectedPerson: nil - ) - controller.onPersonSelected = { [weak self] person in + await MainActor.run { [weak self] 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 + let controller = OrderSourcePickerViewController( + orderNumber: order.orderNumber, + viewModel: self.orderListViewModel, + referralOrder: order.referralOrder, + onPersonSelected: { [weak self] person in + guard let self else { return } + Task { + await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI) + } + }, + onLeadBound: { [weak self] selection in guard let self, let orderNumber = self.pendingOrderSourceNumber else { return } + self.dismiss(animated: true) { + self.orderSourcePicker = nil + } Task { await self.orderListViewModel.bindOrderSource( orderNumber: orderNumber, @@ -475,11 +491,44 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT api: self.orderAPI ) } + }, + onCallPhone: { [weak self] phone in + self?.callPhone(phone) + }, + onPreviewImages: { [weak self] urls, index in + self?.presentImagePreview(images: urls, startIndex: index) + } + ) + self.orderSourcePicker = controller + let nav = UINavigationController(rootViewController: controller) + nav.setNavigationBarHidden(true, animated: false) + if let sheet = nav.sheetPresentationController { + sheet.prefersGrabberVisible = true + if #available(iOS 16.0, *) { + sheet.detents = [ + .custom(identifier: .init("orderSource")) { context in + context.maximumDetentValue * 0.82 + }, + .large(), + ] + sheet.selectedDetentIdentifier = .init("orderSource") + } else { + sheet.detents = [.large()] } - self.present(leadsController, animated: true) } + self.present(nav, animated: true) } - present(controller, animated: true) } } + + private func callPhone(_ phone: String) { + let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: "tel://\(trimmed)") else { return } + UIApplication.shared.open(url) + } + + private func presentImagePreview(images: [String], startIndex: Int) { + guard !images.isEmpty else { return } + present(ImagePreviewViewController(imageURLs: images, startIndex: startIndex), animated: true) + } } diff --git a/suixinkan/UI/Orders/Views/OrderSourceEntryView.swift b/suixinkan/UI/Orders/Views/OrderSourceEntryView.swift new file mode 100644 index 0000000..d8538ae --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderSourceEntryView.swift @@ -0,0 +1,112 @@ +// +// OrderSourceEntryView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 订单卡片内「订单来源」入口,对齐 Android `OrderSourcePicker` 折叠态。 +final class OrderSourceEntryView: UIControl { + + var onImageTap: (([String], Int) -> Void)? + + private let titleLabel = UILabel() + private let chevronView = UIImageView() + private let hintLabel = UILabel() + private let personRow = UIStackView() + private let personNameLabel = UILabel() + private let personPhoneLabel = UILabel() + private let divider = UIView() + private let leadContentView = OrderSourceLeadContentView() + private var currentImages: [String] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 应用订单来源展示状态。 + func apply(selection: OrderSourceSelection?) { + if let selection { + hintLabel.isHidden = true + personRow.isHidden = false + divider.isHidden = false + leadContentView.isHidden = false + + personNameLabel.text = selection.person.name + let phone = selection.person.phone.trimmingCharacters(in: .whitespacesAndNewlines) + personPhoneLabel.text = phone + personPhoneLabel.isHidden = phone.isEmpty + + currentImages = selection.lead.images + leadContentView.apply(lead: selection.lead, compact: true, enablePhoneCall: false) + leadContentView.onImageTap = { [weak self] _, index in + guard let self else { return } + self.onImageTap?(self.currentImages, index) + } + } else { + hintLabel.isHidden = false + personRow.isHidden = true + divider.isHidden = true + leadContentView.isHidden = true + currentImages = [] + } + } + + private func setupUI() { + backgroundColor = OrderTokens.sourceBackground + layer.cornerRadius = AppRadius.sm + layer.borderWidth = 1 + layer.borderColor = OrderTokens.sourceBorder.cgColor + + titleLabel.text = "订单来源" + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = OrderTokens.label563 + + chevronView.image = UIImage(systemName: "chevron.right") + chevronView.tintColor = AppColor.primary + chevronView.contentMode = .scaleAspectFit + + hintLabel.text = "选择获客员及其创建的带客单" + hintLabel.font = .systemFont(ofSize: 13) + hintLabel.textColor = AppColor.text666 + hintLabel.numberOfLines = 0 + + personRow.axis = .horizontal + personRow.alignment = .center + personRow.spacing = 10 + personNameLabel.font = .systemFont(ofSize: 13, weight: .medium) + personNameLabel.textColor = AppColor.text333 + personPhoneLabel.font = .systemFont(ofSize: 13) + personPhoneLabel.textColor = AppColor.text666 + personRow.addArrangedSubview(personNameLabel) + personRow.addArrangedSubview(personPhoneLabel) + + divider.backgroundColor = OrderTokens.sourceDivider + + let headerRow = UIStackView(arrangedSubviews: [titleLabel, UIView(), chevronView]) + headerRow.axis = .horizontal + headerRow.alignment = .center + + let stack = UIStackView(arrangedSubviews: [headerRow, hintLabel, personRow, divider, leadContentView]) + stack.axis = .vertical + stack.spacing = 10 + stack.isUserInteractionEnabled = false + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.sm) + } + chevronView.snp.makeConstraints { make in + make.width.height.equalTo(20) + } + divider.snp.makeConstraints { make in + make.height.equalTo(OrderTokens.dividerHeight) + } + } +} diff --git a/suixinkan/UI/Orders/Views/OrderSourceLeadCell.swift b/suixinkan/UI/Orders/Views/OrderSourceLeadCell.swift new file mode 100644 index 0000000..dddb6aa --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderSourceLeadCell.swift @@ -0,0 +1,106 @@ +// +// OrderSourceLeadCell.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 带客单选择列表 cell,对齐 Android `SourceLeadItem`。 +final class OrderSourceLeadCell: UITableViewCell { + static let reuseIdentifier = "OrderSourceLeadCell" + + var onCallPhone: ((String) -> Void)? + var onImageTap: (([String], Int) -> Void)? + var onSelect: (() -> Void)? + + private let cardView = UIView() + private let tagLabel = UILabel() + private let checkView = UIImageView() + private let contentViewPanel = OrderSourceLeadContentView() + private let actionButton = UIButton(type: .system) + private var currentImages: [String] = [] + + 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(lead: OrderSourceLead, selected: Bool) { + currentImages = lead.images + contentViewPanel.apply(lead: lead, compact: false, enablePhoneCall: true) + contentViewPanel.onCallPhone = { [weak self] phone in self?.onCallPhone?(phone) } + contentViewPanel.onImageTap = { [weak self] images, index in + self?.onImageTap?(images, index) + } + + checkView.isHidden = !selected + cardView.layer.borderColor = (selected ? AppColor.primary : OrderTokens.sourceBorder).cgColor + cardView.backgroundColor = selected ? AppColor.primaryLight.withAlphaComponent(0.4) : .white + actionButton.setTitle(selected ? "当前已选择" : "选择此带客单", for: .normal) + actionButton.setTitleColor(selected ? AppColor.primary : .white, for: .normal) + actionButton.backgroundColor = selected ? AppColor.primaryLight : AppColor.primary + } + + private func setupUI() { + cardView.layer.cornerRadius = AppRadius.sm + cardView.layer.borderWidth = 1 + contentView.addSubview(cardView) + + tagLabel.text = "带客单" + tagLabel.font = .systemFont(ofSize: 13, weight: .medium) + tagLabel.textColor = AppColor.primary + tagLabel.backgroundColor = AppColor.primaryLight.withAlphaComponent(0.8) + tagLabel.layer.cornerRadius = 4 + tagLabel.clipsToBounds = true + tagLabel.textAlignment = .center + + checkView.image = UIImage(systemName: "checkmark") + checkView.tintColor = AppColor.primary + checkView.contentMode = .scaleAspectFit + + actionButton.titleLabel?.font = .systemFont(ofSize: 14) + actionButton.layer.cornerRadius = 6 + actionButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside) + + cardView.addSubview(tagLabel) + cardView.addSubview(checkView) + cardView.addSubview(contentViewPanel) + cardView.addSubview(actionButton) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 0, bottom: 12, right: 0)) + } + tagLabel.snp.makeConstraints { make in + make.leading.top.equalToSuperview().inset(12) + make.height.equalTo(24) + make.width.greaterThanOrEqualTo(56) + } + checkView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(12) + make.centerY.equalTo(tagLabel) + make.width.height.equalTo(20) + } + contentViewPanel.snp.makeConstraints { make in + make.top.equalTo(tagLabel.snp.bottom).offset(10) + make.leading.trailing.equalToSuperview().inset(12) + } + actionButton.snp.makeConstraints { make in + make.top.equalTo(contentViewPanel.snp.bottom).offset(10) + make.leading.trailing.bottom.equalToSuperview().inset(12) + make.height.equalTo(40) + } + } + + @objc private func selectTapped() { + onSelect?() + } +} diff --git a/suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift b/suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift new file mode 100644 index 0000000..49ca0c3 --- /dev/null +++ b/suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift @@ -0,0 +1,138 @@ +// +// OrderSourceLeadContentView.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 带客单摘要内容,对齐 Android `SourceLeadContent`。 +final class OrderSourceLeadContentView: UIView { + + var onCallPhone: ((String) -> Void)? + var onImageTap: (([String], Int) -> Void)? + + private let phoneRow = UIStackView() + private let phoneTitleLabel = UILabel() + private let phoneValueLabel = UILabel() + private let remarkLabel = UILabel() + private let imageRow = UIStackView() + private let emptyImageLabel = UILabel() + + private var currentImages: [String] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 应用带客单展示内容。 + func apply(lead: OrderSourceLead, compact: Bool, enablePhoneCall: Bool = true) { + let phone = lead.userPhone.trimmingCharacters(in: .whitespacesAndNewlines) + phoneRow.isHidden = phone.isEmpty + phoneValueLabel.text = phone + phoneValueLabel.textColor = enablePhoneCall ? AppColor.primary : AppColor.text333 + phoneValueLabel.isUserInteractionEnabled = enablePhoneCall && !phone.isEmpty + + let remark = lead.remark.trimmingCharacters(in: .whitespacesAndNewlines) + remarkLabel.isHidden = remark.isEmpty + remarkLabel.text = remark + remarkLabel.font = compact ? .systemFont(ofSize: 12) : .systemFont(ofSize: 13) + remarkLabel.numberOfLines = compact ? 2 : 4 + + imageRow.arrangedSubviews.forEach { $0.removeFromSuperview() } + emptyImageLabel.isHidden = true + if lead.images.isEmpty { + if !compact { + emptyImageLabel.isHidden = false + } + } else { + currentImages = lead.images + lead.images.prefix(3).enumerated().forEach { index, url in + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 6 + imageView.backgroundColor = OrderTokens.sourceLeadPlaceholder + imageView.loadRemoteImage(urlString: url, placeholderColor: OrderTokens.sourceLeadPlaceholder) + imageView.isUserInteractionEnabled = true + imageView.tag = index + imageView.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:))) + ) + imageRow.addArrangedSubview(imageView) + imageView.snp.makeConstraints { make in + make.height.equalTo(imageView.snp.width) + } + } + let placeholders = max(0, 3 - min(lead.images.count, 3)) + if placeholders > 0 { + (0.. Void)? + + private let cardView = UIView() + private let nameLabel = UILabel() + private let phoneLabel = UILabel() + private let callButton = UIButton(type: .system) + private let chevronView = UIImageView() + private var personPhone = "" + + 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(person: OrderSourcePerson, selected: Bool) { + nameLabel.text = person.name + phoneLabel.text = person.phone + personPhone = person.phone + cardView.layer.borderColor = (selected ? AppColor.primary : OrderTokens.sourceBorder).cgColor + cardView.backgroundColor = selected ? AppColor.primaryLight.withAlphaComponent(0.5) : .white + } + + private func setupUI() { + cardView.layer.cornerRadius = AppRadius.sm + cardView.layer.borderWidth = 1 + contentView.addSubview(cardView) + + nameLabel.font = .systemFont(ofSize: 16, weight: .medium) + nameLabel.textColor = AppColor.text333 + phoneLabel.font = .systemFont(ofSize: 14) + phoneLabel.textColor = AppColor.text666 + + callButton.backgroundColor = OrderTokens.phoneIconBackground + callButton.layer.cornerRadius = 16 + callButton.tintColor = AppColor.primary + callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) + callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) + + chevronView.image = UIImage(systemName: "chevron.right") + chevronView.tintColor = AppColor.text666 + chevronView.contentMode = .scaleAspectFit + + cardView.addSubview(nameLabel) + cardView.addSubview(phoneLabel) + cardView.addSubview(callButton) + cardView.addSubview(chevronView) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 0, bottom: 10, right: 0)) + } + nameLabel.snp.makeConstraints { make in + make.leading.top.equalToSuperview().inset(14) + make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-8) + } + phoneLabel.snp.makeConstraints { make in + make.leading.equalTo(nameLabel) + make.top.equalTo(nameLabel.snp.bottom).offset(6) + make.bottom.equalToSuperview().inset(14) + } + chevronView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(14) + make.centerY.equalToSuperview() + make.width.height.equalTo(20) + } + callButton.snp.makeConstraints { make in + make.trailing.equalTo(chevronView.snp.leading).offset(-8) + make.centerY.equalToSuperview() + make.width.height.equalTo(32) + } + } + + @objc private func callTapped() { + onCallPhone?(personPhone) + } +} diff --git a/suixinkan/UI/Profile/ProfileInfoRowView.swift b/suixinkan/UI/Profile/ProfileInfoRowView.swift index 078ffc7..a21cf3d 100644 --- a/suixinkan/UI/Profile/ProfileInfoRowView.swift +++ b/suixinkan/UI/Profile/ProfileInfoRowView.swift @@ -22,6 +22,10 @@ final class ProfileInfoRowView: UIControl { private let divider = UIView() private let badgeContainer = UIView() private var badgeView: ProfileStatusBadgeView? + private let rightStack = UIStackView() + private let stackNameLabel = UILabel() + private let stackTypeTag = UILabel() + private var rowHeightConstraint: Constraint? var showsDivider = true { didSet { divider.isHidden = !showsDivider } @@ -44,6 +48,8 @@ final class ProfileInfoRowView: UIControl { accessory: AccessoryStyle = .none, badge: ProfileStatusBadgeView.Style? = nil ) { + setStackedModeEnabled(false) + valueLabel.text = value valueLabel.textColor = valueColor valueLabel.isHidden = badge != nil @@ -73,6 +79,84 @@ final class ProfileInfoRowView: UIControl { accessoryLabel.isHidden = false chevronView.isHidden = false } + + updateContentConstraints(for: accessory) + } + + /// 右侧竖排展示店铺名称与账号类型标识,可选右侧箭头。 + func configureAccountInfo( + storeName: String, + accountTypeLabel: String, + isStoreAccount: Bool, + showsChevron: Bool = true + ) { + setStackedModeEnabled(true, showsChevron: showsChevron) + + stackNameLabel.text = storeName + stackTypeTag.text = accountTypeLabel + if isStoreAccount { + stackTypeTag.textColor = UIColor(hex: 0x0F9F6E) + stackTypeTag.backgroundColor = UIColor(hex: 0xE8F8F1) + } else { + stackTypeTag.textColor = UIColor(hex: 0x7C3AED) + stackTypeTag.backgroundColor = UIColor(hex: 0xF3ECFF) + } + + updateStackedConstraints(showsChevron: showsChevron) + } + + private func setStackedModeEnabled(_ enabled: Bool, showsChevron: Bool = false) { + rightStack.isHidden = !enabled + accessoryLabel.isHidden = true + chevronView.isHidden = !enabled || !showsChevron + + if enabled { + valueLabel.isHidden = true + badgeContainer.isHidden = true + rowHeightConstraint?.update(offset: 64) + } else { + rowHeightConstraint?.update(offset: 52) + } + } + + private func updateStackedConstraints(showsChevron: Bool) { + rightStack.snp.remakeConstraints { make in + make.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8) + if showsChevron { + make.trailing.equalTo(chevronView.snp.leading).offset(-4) + } else { + make.trailing.equalToSuperview() + } + } + } + + private func updateContentConstraints(for accessory: AccessoryStyle) { + valueLabel.snp.remakeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8) + make.centerY.equalToSuperview() + switch accessory { + case .action: + make.trailing.equalTo(accessoryLabel.snp.leading).offset(-8) + case .chevron: + make.trailing.equalTo(chevronView.snp.leading).offset(-4) + case .none: + make.trailing.equalToSuperview() + } + } + + badgeContainer.snp.remakeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(8) + make.centerY.equalToSuperview() + switch accessory { + case .action: + make.trailing.lessThanOrEqualTo(accessoryLabel.snp.leading).offset(-8) + case .chevron: + make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8) + case .none: + make.trailing.lessThanOrEqualToSuperview() + } + } } private func setupUI() { @@ -83,13 +167,36 @@ final class ProfileInfoRowView: UIControl { valueLabel.font = .systemFont(ofSize: 14) valueLabel.textAlignment = .right valueLabel.numberOfLines = 1 + valueLabel.lineBreakMode = .byTruncatingTail + valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) accessoryLabel.font = .systemFont(ofSize: 14) accessoryLabel.textColor = AppColor.primary + accessoryLabel.setContentHuggingPriority(.required, for: .horizontal) + accessoryLabel.setContentCompressionResistancePriority(.required, for: .horizontal) chevronView.image = UIImage(systemName: "chevron.right") chevronView.tintColor = AppColor.text666 chevronView.contentMode = .scaleAspectFit + chevronView.setContentHuggingPriority(.required, for: .horizontal) + + stackNameLabel.font = .systemFont(ofSize: 14) + stackNameLabel.textColor = AppColor.text333 + stackNameLabel.textAlignment = .right + stackNameLabel.numberOfLines = 1 + stackNameLabel.lineBreakMode = .byTruncatingTail + + stackTypeTag.font = .systemFont(ofSize: 11, weight: .semibold) + stackTypeTag.textAlignment = .center + stackTypeTag.layer.cornerRadius = 4 + stackTypeTag.clipsToBounds = true + + rightStack.axis = .vertical + rightStack.alignment = .trailing + rightStack.spacing = 4 + rightStack.addArrangedSubview(stackNameLabel) + rightStack.addArrangedSubview(stackTypeTag) + rightStack.isHidden = true divider.backgroundColor = UIColor(hex: 0xE5E7EB) @@ -98,10 +205,11 @@ final class ProfileInfoRowView: UIControl { addSubview(badgeContainer) addSubview(accessoryLabel) addSubview(chevronView) + addSubview(rightStack) addSubview(divider) snp.makeConstraints { make in - make.height.equalTo(52) + rowHeightConstraint = make.height.equalTo(52).constraint } titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview() @@ -117,16 +225,16 @@ final class ProfileInfoRowView: UIControl { make.trailing.equalTo(chevronView.snp.leading).offset(-4) make.centerY.equalToSuperview() } - valueLabel.snp.makeConstraints { make in + rightStack.snp.makeConstraints { make in + make.trailing.equalToSuperview() + make.centerY.equalToSuperview() make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8) - make.trailing.equalTo(chevronView.snp.leading).offset(-4) - make.centerY.equalToSuperview() } - badgeContainer.snp.makeConstraints { make in - make.leading.equalTo(titleLabel.snp.trailing).offset(8) - make.centerY.equalToSuperview() - make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8) + stackTypeTag.snp.makeConstraints { make in + make.height.equalTo(22) + make.width.greaterThanOrEqualTo(56) } + updateContentConstraints(for: .none) divider.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() make.height.equalTo(0.5) diff --git a/suixinkan/UI/Profile/ProfileViewController.swift b/suixinkan/UI/Profile/ProfileViewController.swift index 20775e8..02577ce 100644 --- a/suixinkan/UI/Profile/ProfileViewController.swift +++ b/suixinkan/UI/Profile/ProfileViewController.swift @@ -31,6 +31,7 @@ final class ProfileViewController: BaseViewController { private let statusRow = ProfileInfoRowView(title: "账号状态") private let scenicRow = ProfileInfoRowView(title: "当前景区") private let logoutButton = UIButton(type: .system) + private var hasLoadedProfileOnce = false override func setupNavigationBar() { title = "我的" @@ -101,7 +102,13 @@ final class ProfileViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - Task { await reloadProfile(showGlobalLoading: true) } + let showLoading = !hasLoadedProfileOnce + Task { + await reloadProfile(showGlobalLoading: showLoading) + if showLoading { + hasLoadedProfileOnce = true + } + } } private let headerContainer = UIView() @@ -194,9 +201,10 @@ final class ProfileViewController: BaseViewController { avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL) nameRow.configure(value: viewModel.displayRealName) - accountRow.configure( - value: viewModel.accountDisplayName, - accessory: .action("切换账号") + accountRow.configureAccountInfo( + storeName: viewModel.accountDisplayName, + accountTypeLabel: viewModel.accountTypeLabel, + isStoreAccount: viewModel.isStoreAccount ) phoneRow.configure(value: viewModel.displayPhone) diff --git a/suixinkanTests/BindAcquirerViewModelTests.swift b/suixinkanTests/BindAcquirerViewModelTests.swift new file mode 100644 index 0000000..79e4234 --- /dev/null +++ b/suixinkanTests/BindAcquirerViewModelTests.swift @@ -0,0 +1,86 @@ +// +// BindAcquirerViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +final class BindAcquirerViewModelTests: XCTestCase { + + func testAcceptBindValidationRequiresRate() async { + let viewModel = BindAcquirerViewModel() + viewModel.initSaleUserId(1) + var message: String? + viewModel.onShowMessage = { message = $0 } + + await viewModel.acceptBind(api: makeAPI(responses: [])) + + XCTAssertEqual(message, "请输入分账比例") + } + + func testAcceptBindValidationRejectsOutOfRange() async { + let viewModel = BindAcquirerViewModel() + viewModel.initSaleUserId(1) + viewModel.updateCommissionRateDraft("120") + var message: String? + viewModel.onShowMessage = { message = $0 } + + await viewModel.acceptBind(api: makeAPI(responses: [])) + + XCTAssertEqual(message, "分账比例需在0到100之间") + } + + func testLoadInviteInfoMarksAlreadyBound() async throws { + let json = """ + {"code":100000,"msg":"success","data":{"sale_user_id":7,"is_bound":true,"saler_name":"张三","saler_phone":"13800138000","title":"接受邀请"}} + """.data(using: .utf8)! + let api = makeAPI(responses: [json]) + let viewModel = BindAcquirerViewModel() + viewModel.initSaleUserId(7) + + await viewModel.loadInviteInfo(api: api) + + XCTAssertTrue(viewModel.isAlreadyBound) + XCTAssertEqual(viewModel.inviteInfo?.salerName, "张三") + } + + func testAcceptBindSuccessCallsCallback() async throws { + let inviteJSON = """ + {"code":100000,"msg":"success","data":{"sale_user_id":2,"saler_name":"李四"}} + """.data(using: .utf8)! + let bindJSON = """ + {"code":100000,"msg":"success","data":{}} + """.data(using: .utf8)! + let api = makeAPI(responses: [inviteJSON, bindJSON]) + let viewModel = BindAcquirerViewModel() + viewModel.initSaleUserId(2) + viewModel.updateCommissionRateDraft("30") + var didBind = false + viewModel.onBindSuccess = { didBind = true } + + await viewModel.loadInviteInfo(api: api) + await viewModel.acceptBind(api: api) + + XCTAssertTrue(didBind) + } + + func testLoadInviteInfoAlreadyBoundMessage() async throws { + let json = try TestJSON.errorEnvelope(code: 100001, msg: "已绑定获客员") + let api = makeAPI(responses: [json]) + let viewModel = BindAcquirerViewModel() + viewModel.initSaleUserId(9) + var shouldPop = false + viewModel.onShouldPop = { shouldPop = true } + + await viewModel.loadInviteInfo(api: api) + + XCTAssertTrue(viewModel.isAlreadyBound) + XCTAssertFalse(shouldPop) + } + + private func makeAPI(responses: [Data]) -> OrderAPI { + OrderAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: responses))) + } +} diff --git a/suixinkanTests/CooperationAcquirerViewModelTests.swift b/suixinkanTests/CooperationAcquirerViewModelTests.swift new file mode 100644 index 0000000..ae78a43 --- /dev/null +++ b/suixinkanTests/CooperationAcquirerViewModelTests.swift @@ -0,0 +1,88 @@ +// +// CooperationAcquirerViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +final class CooperationAcquirerViewModelTests: XCTestCase { + + private var appStore: AppStore! + private var defaults: UserDefaults! + + override func setUp() { + defaults = UserDefaults(suiteName: "CooperationAcquirerViewModelTests")! + defaults.removePersistentDomain(forName: "CooperationAcquirerViewModelTests") + appStore = AppStore(defaults: defaults) + appStore.currentStoreId = 5 + } + + func testSaveRemarkWithValidLengthCallsAPI() async throws { + let json = """ + {"code":100000,"msg":"success","data":{}} + """.data(using: .utf8)! + let viewModel = CooperationAcquirerViewModel(appStore: appStore) + var message: String? + viewModel.onShowMessage = { message = $0 } + viewModel.startEditRemark(CooperativeSalerEntity(saleUserId: 1)) + viewModel.updateRemarkDraft("备注A") + + await viewModel.saveRemark(api: makeAPI(responses: [json])) + + XCTAssertEqual(message, "备注已保存") + } + + func testSaveCommissionRateRequiresSmsCode() async { + let viewModel = CooperationAcquirerViewModel(appStore: appStore) + var message: String? + viewModel.onShowMessage = { message = $0 } + viewModel.startEditCommissionRate(CooperativeSalerEntity(saleUserId: 1, salerPhone: "13800138000")) + viewModel.updateCommissionRateDraft("20") + + await viewModel.saveCommissionRate(api: makeAPI(responses: [])) + + XCTAssertEqual(message, "请输入验证码") + } + + func testProcessScanResultNavigatesWithValidQR() { + let viewModel = CooperationAcquirerViewModel(appStore: appStore) + var navigatedId: Int? + viewModel.onNavigateBindAcquirer = { navigatedId = $0 } + + viewModel.processScanResult(#"{"method":"sale_user_bind","sale_user_id":88}"#) + + XCTAssertEqual(navigatedId, 88) + } + + func testProcessScanResultShowsErrorForInvalidQR() { + let viewModel = CooperationAcquirerViewModel(appStore: appStore) + var message: String? + viewModel.onNavigateBindAcquirer = { _ in XCTFail("Should not navigate") } + viewModel.onShowMessage = { message = $0 } + + viewModel.processScanResult("invalid") + + XCTAssertEqual(message, "请扫描正确的获客员二维码") + } + + func testLoadAcquirersUsesStoreId() async throws { + let json = """ + {"code":100000,"msg":"success","data":{"total":1,"list":[{"sale_user_id":3,"name":"获客员A"}]}} + """.data(using: .utf8)! + let session = MockURLSession(responses: [json]) + let api = OrderAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = CooperationAcquirerViewModel(appStore: appStore) + + await viewModel.loadAcquirers(api: api) + + XCTAssertEqual(viewModel.acquirers.count, 1) + let url = try XCTUnwrap(session.requests.first?.url?.absoluteString) + XCTAssertTrue(url.contains("store_id=5")) + } + + private func makeAPI(responses: [Data]) -> OrderAPI { + OrderAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: responses))) + } +} diff --git a/suixinkanTests/CooperationOrderListViewModelTests.swift b/suixinkanTests/CooperationOrderListViewModelTests.swift new file mode 100644 index 0000000..dedc573 --- /dev/null +++ b/suixinkanTests/CooperationOrderListViewModelTests.swift @@ -0,0 +1,63 @@ +// +// CooperationOrderListViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +final class CooperationOrderListViewModelTests: XCTestCase { + + func testRefreshLeadsLoadsFirstPage() async throws { + let json = """ + {"code":100000,"msg":"success","data":{"total":1,"list":[{"id":1,"user_phone":"13800138000","created_at":"2026-01-01"}]}} + """.data(using: .utf8)! + let api = OrderAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [json]))) + let viewModel = CooperationOrderListViewModel() + + await viewModel.refreshLeads(api: api) + + XCTAssertEqual(viewModel.leads.count, 1) + XCTAssertFalse(viewModel.canLoadMore) + XCTAssertFalse(viewModel.isRefreshing) + } + + func testSearchUsesKeywordQuery() async throws { + let json = """ + {"code":100000,"msg":"success","data":{"total":0,"list":[]}} + """.data(using: .utf8)! + let session = MockURLSession(responses: [json]) + let api = OrderAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = CooperationOrderListViewModel() + viewModel.onTabSelected(1) + viewModel.onSearchTextChange("张三") + + await viewModel.refreshAcquisitionOrders(api: api) + + let url = try XCTUnwrap(session.requests.first?.url?.absoluteString) + XCTAssertTrue(url.contains("acquisition-orders")) + XCTAssertTrue(url.contains("keyword=")) + } + + func testLoadMoreAppendsLeads() async throws { + let page1 = """ + {"code":100000,"msg":"success","data":{"total":2,"list":[{"id":1,"user_phone":"13800138000"}]}} + """.data(using: .utf8)! + let page2 = """ + {"code":100000,"msg":"success","data":{"total":2,"list":[{"id":2,"user_phone":"13800138001"}]}} + """.data(using: .utf8)! + let api = OrderAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [page1, page2]))) + let viewModel = CooperationOrderListViewModel() + + await viewModel.refreshLeads(api: api) + await viewModel.loadMore(api: api) + + XCTAssertEqual(viewModel.leads.count, 2) + } + + func testShouldLoadMoreNearEnd() { + let viewModel = CooperationOrderListViewModel() + XCTAssertFalse(viewModel.shouldLoadMore(at: 0)) + } +} diff --git a/suixinkanTests/CooperationOrderModelsTests.swift b/suixinkanTests/CooperationOrderModelsTests.swift new file mode 100644 index 0000000..491a6c2 --- /dev/null +++ b/suixinkanTests/CooperationOrderModelsTests.swift @@ -0,0 +1,68 @@ +// +// CooperationOrderModelsTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 合作订单模型与校验测试。 +final class CooperationOrderModelsTests: XCTestCase { + + func testRemarkNameValidationAllowsTenCharacters() { + XCTAssertNil(CooperationOrderFeature.remarkNameValidationMessage("1234567890")) + } + + func testRemarkNameValidationRejectsOverTenCharacters() { + XCTAssertEqual( + CooperationOrderFeature.remarkNameValidationMessage("12345678901"), + "备注名不能超过10个字符" + ) + } + + func testReferralLeadDisplayFields() throws { + let json = """ + { + "id": 1, + "lead_id": 9, + "display_name": "展示名", + "user_mobile_masked": "138****0000", + "remark": "备注", + "created_at": "2026-01-01", + "image_urls": ["https://img/1.jpg"] + } + """.data(using: .utf8)! + let lead = try JSONDecoder().decode(ReferralLeadEntity.self, from: json) + XCTAssertEqual(lead.displayId, "9") + XCTAssertEqual(lead.displayPhone, "138****0000") + XCTAssertEqual(lead.displaySalerName, "展示名") + XCTAssertEqual(lead.displayImages, ["https://img/1.jpg"]) + } + + func testAcquisitionOrderDisplayFields() throws { + let json = """ + { + "project_name": "跟拍套餐", + "order_type_label": "摄影师跟拍", + "order_number": "NO001", + "photog_remark_name": "备注名", + "sale_user_name": "获客员", + "created_at": "2026-01-02", + "user_mobile_masked": "139****1111", + "order_amount": "99.00" + } + """.data(using: .utf8)! + let order = try JSONDecoder().decode(AcquisitionOrderEntity.self, from: json) + XCTAssertEqual(order.displayPartner, "备注名") + XCTAssertEqual(order.displayCustomerPhone, "139****1111") + XCTAssertEqual(order.displayAmount, "99.00") + } + + func testCooperativeSalerDisplayCommissionRateUsesLabelFirst() { + let entity = CooperativeSalerEntity( + commissionRate: 10, + commissionRateLabel: "10%(固定)" + ) + XCTAssertEqual(entity.displayCommissionRate, "10%(固定)") + } +} diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index 13d21c3..3e840ca 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -148,4 +148,27 @@ final class HomeViewModelTests: XCTestCase { XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"]) XCTAssertEqual(session.requests.count, 1) } + + func testRefreshLocalDisplayStateClearsWhenNameMissing() { + appStore.currentScenicId = 10 + appStore.currentScenicName = "" + let viewModel = HomeViewModel(appStore: appStore) + + viewModel.refreshLocalDisplayState() + + XCTAssertEqual(viewModel.currentScenicName, "") + XCTAssertEqual(appStore.currentScenicId, 0) + XCTAssertEqual(appStore.currentScenicName, "") + } + + func testRefreshLocalDisplayStateClearsWhenIdMissing() { + appStore.currentScenicId = 0 + appStore.currentScenicName = "残留名称" + let viewModel = HomeViewModel(appStore: appStore) + + viewModel.refreshLocalDisplayState() + + XCTAssertEqual(viewModel.currentScenicName, "") + XCTAssertEqual(appStore.currentScenicName, "") + } } diff --git a/suixinkanTests/OrderListViewModelTests.swift b/suixinkanTests/OrderListViewModelTests.swift index e512b26..8b7b77e 100644 --- a/suixinkanTests/OrderListViewModelTests.swift +++ b/suixinkanTests/OrderListViewModelTests.swift @@ -166,4 +166,69 @@ final class OrderListViewModelTests: XCTestCase { XCTAssertEqual(list[1].photoTravel?.orderPhotoNum, 6) XCTAssertEqual(list[1].photoTravel?.needCheckIn, true) } + + func testReferralOrderEntityDecodesImages() throws { + let json = """ + { + "id": 1, + "referral_order_no": "LD001", + "order_no": "LD001", + "user_mobile": "13800138000", + "remark": "测试备注", + "created_at": "2026-06-25", + "status": 1, + "status_label": "待处理", + "images": ["https://cdn.example.com/a.jpg", "https://cdn.example.com/b.jpg"] + } + """.data(using: .utf8)! + + let entity = try JSONDecoder().decode(ReferralOrderEntity.self, from: json) + XCTAssertEqual(entity.images.count, 2) + XCTAssertEqual(OrderSourceMapping.lead(from: entity).remark, "测试备注") + } + + func testOrderSourceLeadMappingUsesStatusLabelFallback() throws { + let json = """ + { + "id": 1, + "referral_order_no": "LD002", + "order_no": "LD002", + "user_mobile": "13800138000", + "remark": "", + "created_at": "", + "status": 1, + "status_label": "已完成", + "images": [] + } + """.data(using: .utf8)! + let entity = try JSONDecoder().decode(ReferralOrderEntity.self, from: json) + XCTAssertEqual(OrderSourceMapping.lead(from: entity).remark, "已完成") + } + + func testOrderSourceSelectionFromBoundReferralUsesRemarkFallback() throws { + let json = """ + { + "id": 53, + "order_no": "LD202606250008", + "sale_user_id": 6, + "saler_name": "管铜彬", + "photog_remark_name": "管铜彬1234567", + "saler_phone": "19281871413", + "display_text": "管铜彬1234567 19281871413", + "user_mobile": "13333333331", + "remark": "", + "bind_order_number": "260625128009", + "source_type": 2, + "source_type_label": "下级线索", + "status": 2, + "status_label": "已完成", + "created_at": "2026-06-25 17:13:51" + } + """.data(using: .utf8)! + let referral = try JSONDecoder().decode(BoundReferralOrderEntity.self, from: json) + let selection = OrderSourceMapping.selection(from: referral) + XCTAssertEqual(selection?.person.name, "管铜彬1234567") + XCTAssertEqual(selection?.lead.remark, "管铜彬1234567 19281871413") + XCTAssertEqual(selection?.lead.key, "LD202606250008") + } } diff --git a/suixinkanTests/PermissionApplyStatusViewModelTests.swift b/suixinkanTests/PermissionApplyStatusViewModelTests.swift new file mode 100644 index 0000000..901b467 --- /dev/null +++ b/suixinkanTests/PermissionApplyStatusViewModelTests.swift @@ -0,0 +1,56 @@ +// +// PermissionApplyStatusViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 权限申请审核状态 ViewModel 测试。 +final class PermissionApplyStatusViewModelTests: XCTestCase { + + func testLoadPendingDataMatchesApplyCode() async throws { + let json = """ + {"code":100000,"msg":"success","data":[{"code":"AP001","role_id":1,"role_name":"摄影师","scenic_list":[{"id":1,"name":"景区A"}],"status":1,"status_label":"审核中","created_at":"2026-01-01","audit_note":"","audited_at":null,"audited_by":null}]} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [json]))) + let viewModel = PermissionApplyStatusViewModel(applyCode: "AP001") + + await viewModel.loadPendingData(api: api) + + XCTAssertEqual(viewModel.pendingData?.code, "AP001") + XCTAssertEqual(viewModel.pendingData?.status, 1) + XCTAssertFalse(viewModel.isLoading) + } + + func testNavigateToEditWhenRejected() async throws { + let json = """ + {"code":100000,"msg":"success","data":[{"code":"AP002","role_id":2,"role_name":"管理员","scenic_list":[],"status":3,"status_label":"已驳回","created_at":"","audit_note":"资料不全","audited_at":null,"audited_by":null}]} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [json]))) + let viewModel = PermissionApplyStatusViewModel(applyCode: "AP002") + await viewModel.loadPendingData(api: api) + + var edited: RoleApplyPendingResponse? + viewModel.onNavigateToEdit = { edited = $0 } + viewModel.navigateToEditIfRejected() + + XCTAssertEqual(edited?.status, 3) + } + + func testNavigateToEditIgnoredWhenReviewing() async throws { + let json = """ + {"code":100000,"msg":"success","data":[{"code":"AP003","role_id":1,"role_name":"摄影师","scenic_list":[],"status":1,"status_label":"审核中","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [json]))) + let viewModel = PermissionApplyStatusViewModel(applyCode: "AP003") + await viewModel.loadPendingData(api: api) + + var edited: RoleApplyPendingResponse? + viewModel.onNavigateToEdit = { edited = $0 } + viewModel.navigateToEditIfRejected() + + XCTAssertNil(edited) + } +} diff --git a/suixinkanTests/PermissionApplyViewModelTests.swift b/suixinkanTests/PermissionApplyViewModelTests.swift new file mode 100644 index 0000000..0814c99 --- /dev/null +++ b/suixinkanTests/PermissionApplyViewModelTests.swift @@ -0,0 +1,85 @@ +// +// PermissionApplyViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 权限申请 ViewModel 测试。 +final class PermissionApplyViewModelTests: XCTestCase { + + private var appStore: AppStore! + private var defaults: UserDefaults! + + override func setUp() { + defaults = UserDefaults(suiteName: "PermissionApplyViewModelTests")! + defaults.removePersistentDomain(forName: "PermissionApplyViewModelTests") + appStore = AppStore(defaults: defaults) + appStore.saveRolePermissionList([ + RolePermissionResponse( + role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"), + scenic: [ScenicInfo(id: 100, name: "已有景区", status: 1)] + ), + ]) + } + + override func tearDown() { + appStore.logout() + super.tearDown() + } + + func testExistingScenicIsDisabledAfterLoad() async throws { + let scenicJSON = """ + {"code":100000,"msg":"success","data":{"total":2,"list":[{"id":100,"name":"已有景区"},{"id":101,"name":"新景区"}]}} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON]))) + let viewModel = PermissionApplyViewModel(appStore: appStore) + viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "拍摄", isSelected: true)) + + await viewModel.loadAvailableScenics(api: api) + + let existing = viewModel.availableScenics.first { $0.id == 100 } + let fresh = viewModel.availableScenics.first { $0.id == 101 } + XCTAssertEqual(existing?.isDisabled, true) + XCTAssertEqual(existing?.isSelected, true) + XCTAssertEqual(fresh?.isDisabled, false) + } + + func testSubmitValidationRequiresRoleAndScenic() async { + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: []))) + let viewModel = PermissionApplyViewModel(appStore: appStore) + var message: String? + viewModel.onShowMessage = { message = $0 } + + await viewModel.submit(api: api) + XCTAssertEqual(message, "请先选择角色") + + viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) + await viewModel.submit(api: api) + XCTAssertEqual(message, "请至少选择一个景区") + } + + func testSubmitSuccessReturnsApplyCode() async throws { + let scenicJSON = """ + {"code":100000,"msg":"success","data":{"total":1,"list":[{"id":101,"name":"新景区"}]}} + """.data(using: .utf8)! + let submitJSON = """ + {"code":100000,"msg":"success","data":{"code":"AP999"}} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [scenicJSON, submitJSON]))) + let viewModel = PermissionApplyViewModel(appStore: appStore) + viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) + await viewModel.loadAvailableScenics(api: api) + if let scenic = viewModel.availableScenics.first(where: { $0.id == 101 }) { + viewModel.toggleScenic(scenic) + } + + var applyCode: String? + viewModel.onSubmitSuccess = { applyCode = $0 } + await viewModel.submit(api: api) + + XCTAssertEqual(applyCode, "AP999") + } +} diff --git a/suixinkanTests/ProfileViewModelTests.swift b/suixinkanTests/ProfileViewModelTests.swift index fdc91aa..c507d18 100644 --- a/suixinkanTests/ProfileViewModelTests.swift +++ b/suixinkanTests/ProfileViewModelTests.swift @@ -44,6 +44,22 @@ final class ProfileViewModelTests: XCTestCase { } } + func testAccountTypeLabelForStoreAccount() { + AppStore.shared.accountType = V9StoreUser.accountTypeValue + let viewModel = ProfileViewModel() + XCTAssertEqual(viewModel.accountTypeLabel, "门店账号") + XCTAssertTrue(viewModel.isStoreAccount) + AppStore.shared.accountType = "" + } + + func testAccountTypeLabelForScenicAccount() { + AppStore.shared.accountType = V9ScenicUser.accountTypeValue + let viewModel = ProfileViewModel() + XCTAssertEqual(viewModel.accountTypeLabel, "景区账号") + XCTAssertFalse(viewModel.isStoreAccount) + AppStore.shared.accountType = "" + } + func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() { AppStore.shared.userName = "全局昵称" AppStore.shared.avatar = "https://cdn.example.com/new.jpg" diff --git a/suixinkanTests/ScenicDistanceCalculatorTests.swift b/suixinkanTests/ScenicDistanceCalculatorTests.swift new file mode 100644 index 0000000..0b1a4f3 --- /dev/null +++ b/suixinkanTests/ScenicDistanceCalculatorTests.swift @@ -0,0 +1,28 @@ +// +// ScenicDistanceCalculatorTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 景区距离计算测试。 +final class ScenicDistanceCalculatorTests: XCTestCase { + + func testDistanceKilometersBetweenKnownPoints() { + // 北京天安门 ~ 故宫,约 0.9km + let km = ScenicDistanceCalculator.distanceKilometers( + fromLat: 39.9087, + fromLng: 116.3975, + toLat: 39.9163, + toLng: 116.3972 + ) + XCTAssertGreaterThan(km, 0.5) + XCTAssertLessThan(km, 1.5) + } + + func testFormatDistanceKilometers() { + XCTAssertEqual(ScenicDistanceCalculator.formatDistanceKilometers(1.234), "1.23") + XCTAssertEqual(ScenicDistanceCalculator.formatDistanceKilometers(0), "0") + } +} diff --git a/suixinkanTests/ScenicSelectionViewModelTests.swift b/suixinkanTests/ScenicSelectionViewModelTests.swift new file mode 100644 index 0000000..91e4091 --- /dev/null +++ b/suixinkanTests/ScenicSelectionViewModelTests.swift @@ -0,0 +1,102 @@ +// +// ScenicSelectionViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 景区选择 ViewModel 测试。 +final class ScenicSelectionViewModelTests: XCTestCase { + + private var appStore: AppStore! + private var defaults: UserDefaults! + + override func setUp() { + defaults = UserDefaults(suiteName: "ScenicSelectionViewModelTests")! + defaults.removePersistentDomain(forName: "ScenicSelectionViewModelTests") + appStore = AppStore(defaults: defaults) + } + + override func tearDown() { + appStore.logout() + super.tearDown() + } + + func testSearchFiltersByNameAndAddress() { + appStore.saveRoleScenicList([ + ScenicInfo(id: 1, name: "西湖景区", status: 1, location: ScenicLocationInfo(lng: 120.1, lat: 30.2, address: "杭州西湖"), coverImg: nil), + ScenicInfo(id: 2, name: "灵隐寺", status: 1, location: ScenicLocationInfo(lng: 120.2, lat: 30.3, address: "杭州灵隐"), coverImg: nil), + ]) + let viewModel = ScenicSelectionViewModel(appStore: appStore) + + viewModel.loadFromLocal() + XCTAssertEqual(viewModel.spots.count, 2) + + viewModel.onSearchQueryChange("西湖") + XCTAssertEqual(viewModel.spots.count, 1) + XCTAssertEqual(viewModel.spots.first?.name, "西湖景区") + + viewModel.onSearchQueryChange("灵隐") + XCTAssertEqual(viewModel.spots.count, 1) + XCTAssertEqual(viewModel.spots.first?.name, "灵隐寺") + } + + func testNearestTagWhenLocationProvided() { + appStore.saveRoleScenicList([ + ScenicInfo(id: 1, name: "近", status: 1, location: ScenicLocationInfo(lng: 116.40, lat: 39.91, address: "A"), coverImg: nil), + ScenicInfo(id: 2, name: "远", status: 1, location: ScenicLocationInfo(lng: 116.50, lat: 39.95, address: "B"), coverImg: nil), + ]) + let items = ScenicSpotDisplayMapper.buildItems( + scenicList: appStore.roleScenicList(), + searchQuery: "", + currentLat: 39.9087, + currentLng: 116.3975, + selectedScenicId: 0 + ) + XCTAssertEqual(items.first?.openStatus, .nearest) + } + + func testClosedStatusTag() { + appStore.saveRoleScenicList([ + ScenicInfo(id: 1, name: "停业", status: 2, location: nil, coverImg: nil), + ]) + let viewModel = ScenicSelectionViewModel(appStore: appStore) + viewModel.loadFromLocal() + XCTAssertEqual(viewModel.spots.first?.openStatus, .closed) + XCTAssertEqual(viewModel.spots.first?.statusLabel, "暂未营业") + } + + func testSelectSpotWritesAppStore() { + appStore.saveRoleScenicList([ + ScenicInfo(id: 5, name: "测试景区", status: 1), + ]) + let viewModel = ScenicSelectionViewModel(appStore: appStore) + var completed = false + viewModel.onSelectionComplete = { completed = true } + + viewModel.loadFromLocal() + viewModel.selectSpot(viewModel.spots[0]) + + XCTAssertEqual(appStore.currentScenicId, 5) + XCTAssertEqual(appStore.currentScenicName, "测试景区") + XCTAssertTrue(completed) + XCTAssertTrue(viewModel.spots.first?.isSelected == true) + } + + func testOnApplyNowRoutesToStatusWhenPendingReviewing() async throws { + let pendingJSON = """ + {"code":100000,"msg":"success","data":[{"code":"AP001","role_id":1,"role_name":"摄影师","scenic_list":[],"status":1,"status_label":"审核中","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]} + """.data(using: .utf8)! + let api = await MainActor.run { + HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [pendingJSON]))) + } + let viewModel = ScenicSelectionViewModel(appStore: appStore) + var statusCode: String? + viewModel.onNavigateApplyStatus = { statusCode = $0 } + + await viewModel.onApplyNow(api: api) + + XCTAssertEqual(statusCode, "AP001") + } +}