Add scenic selection and cooperation order flows aligned with Android.
Upgrade scenic picker with search, location sorting, permission apply/status, and blocking home dialog; add cooperation order module and order source picker improvements with unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
suixinkan/Features/Home/Models/PermissionApplyModels.swift
Normal file
107
suixinkan/Features/Home/Models/PermissionApplyModels.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
149
suixinkan/Features/Home/Models/ScenicSelectionModels.swift
Normal file
149
suixinkan/Features/Home/Models/ScenicSelectionModels.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,7 +47,7 @@ enum HomeRouteHandler {
|
|||||||
showToast("暂无合作订单权限", from: viewController)
|
showToast("暂无合作订单权限", from: viewController)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showDeveloping(from: viewController)
|
viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true)
|
||||||
case "task_management", "task_management_editor":
|
case "task_management", "task_management_editor":
|
||||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -212,9 +212,17 @@ final class HomeViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 刷新景区名与角色展示状态。
|
/// 刷新景区名与角色展示状态,对齐 Android `initCurrentScenicName`。
|
||||||
func refreshLocalDisplayState() {
|
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
|
currentAppRole = appStore.currentAppRole
|
||||||
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
|
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Int>()
|
||||||
|
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<Int> {
|
||||||
|
Set(existingScenics(for: roleId).map(\.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -206,6 +206,93 @@ final class OrderAPI {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func referralLeads(page: String = "1", pageSize: String = "10") async throws -> OrderListResponse<ReferralLeadEntity> {
|
||||||
|
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<AcquisitionOrderEntity> {
|
||||||
|
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 {
|
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||||
try await client.send(
|
try await client.send(
|
||||||
APIRequest(
|
APIRequest(
|
||||||
|
|||||||
@ -789,10 +789,19 @@ struct CooperativeSalerEntity: Decodable, Equatable {
|
|||||||
let photogRemarkName: String
|
let photogRemarkName: String
|
||||||
let phone: String
|
let phone: String
|
||||||
let salerPhone: String
|
let salerPhone: String
|
||||||
|
let commissionRate: Int
|
||||||
|
let commissionRateLabel: String
|
||||||
|
let bindTime: String
|
||||||
|
let boundAt: String
|
||||||
|
|
||||||
var displayId: Int { saleUserId }
|
var displayId: Int { saleUserId }
|
||||||
var displayName: String { OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) }
|
var displayName: String { OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) }
|
||||||
var displayPhone: String { salerPhone.isEmpty ? phone : salerPhone }
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case saleUserId = "sale_user_id"
|
case saleUserId = "sale_user_id"
|
||||||
@ -802,6 +811,67 @@ struct CooperativeSalerEntity: Decodable, Equatable {
|
|||||||
case photogRemarkName = "photog_remark_name"
|
case photogRemarkName = "photog_remark_name"
|
||||||
case phone
|
case phone
|
||||||
case salerPhone = "saler_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 createdAt: String
|
||||||
let status: Int
|
let status: Int
|
||||||
let statusLabel: String
|
let statusLabel: String
|
||||||
|
let images: [String]
|
||||||
|
|
||||||
var displayReferralOrderNo: String { referralOrderNo.isEmpty ? orderNo : referralOrderNo }
|
var displayReferralOrderNo: String { referralOrderNo.isEmpty ? orderNo : referralOrderNo }
|
||||||
|
|
||||||
@ -831,6 +902,20 @@ struct ReferralOrderEntity: Decodable, Equatable {
|
|||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case status
|
case status
|
||||||
case statusLabel = "status_label"
|
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)) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
|
|||||||
var onShowWriteOff: ((WriteOffOrderResponse) -> Void)?
|
var onShowWriteOff: ((WriteOffOrderResponse) -> Void)?
|
||||||
var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)?
|
var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)?
|
||||||
var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)?
|
var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)?
|
||||||
|
var onNavigateBindAcquirer: ((Int) -> Void)?
|
||||||
|
|
||||||
private let api: OrderAPI
|
private let api: OrderAPI
|
||||||
private let locationManager = CLLocationManager()
|
private let locationManager = CLLocationManager()
|
||||||
@ -64,7 +65,7 @@ final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
|
|||||||
await fetchSpotsForMultiVerify(pending: pending)
|
await fetchSpotsForMultiVerify(pending: pending)
|
||||||
case SaleUserQrParser.methodSaleUserBind:
|
case SaleUserQrParser.methodSaleUserBind:
|
||||||
if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 {
|
if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 {
|
||||||
onShowMessage?("获客员绑定功能待接入")
|
onNavigateBindAcquirer?(id)
|
||||||
} else {
|
} else {
|
||||||
onShowMessage?("请扫描正确的获客员二维码")
|
onShowMessage?("请扫描正确的获客员二维码")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,9 +20,59 @@ struct OrderSourcePerson: Equatable {
|
|||||||
|
|
||||||
struct OrderSourceLead: Equatable {
|
struct OrderSourceLead: Equatable {
|
||||||
let key: String
|
let key: String
|
||||||
let displayText: String
|
let userPhone: String
|
||||||
let userMobile: String
|
let remark: String
|
||||||
let createdAt: 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 {
|
struct OrderSourceSelection: Equatable {
|
||||||
@ -54,13 +104,14 @@ final class OrderListViewModel {
|
|||||||
private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:]
|
private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:]
|
||||||
private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
||||||
private(set) var loadingOrderSourceLeadsFor: String?
|
private(set) var loadingOrderSourceLeadsFor: String?
|
||||||
|
private(set) var isLoadingOrderSourcePeople = false
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onNavigateBindAcquirer: ((Int) -> Void)?
|
||||||
|
|
||||||
private var currentPage = 1
|
private var currentPage = 1
|
||||||
private var totalCount = 0
|
private var totalCount = 0
|
||||||
private var loadingOrderSourcePeople = false
|
|
||||||
|
|
||||||
private let apiDateFormatter: DateFormatter = {
|
private let apiDateFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
@ -188,7 +239,7 @@ final class OrderListViewModel {
|
|||||||
return
|
return
|
||||||
case SaleUserQrParser.methodSaleUserBind:
|
case SaleUserQrParser.methodSaleUserBind:
|
||||||
if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 {
|
if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 {
|
||||||
onShowMessage?("获客员绑定功能待接入")
|
onNavigateBindAcquirer?(saleUserId)
|
||||||
} else {
|
} else {
|
||||||
onShowMessage?("请扫描正确的获客员二维码")
|
onShowMessage?("请扫描正确的获客员二维码")
|
||||||
}
|
}
|
||||||
@ -287,27 +338,27 @@ final class OrderListViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func orderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
|
func orderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
|
||||||
if let selection = orderSourceSelections[order.orderNumber] { return selection }
|
orderSourceSelection(orderNumber: order.orderNumber, referralOrder: order.referralOrder)
|
||||||
guard let referral = order.referralOrder else { return nil }
|
}
|
||||||
let person = OrderSourcePerson(
|
|
||||||
key: String(referral.saleUserId),
|
/// 按订单号读取当前绑定状态,供订单来源 Sheet 使用。
|
||||||
name: referral.displaySalerName,
|
func orderSourceSelection(orderNumber: String, referralOrder: BoundReferralOrderEntity? = nil) -> OrderSourceSelection? {
|
||||||
phone: referral.salerPhone
|
if let selection = orderSourceSelections[orderNumber] { return selection }
|
||||||
)
|
if let referral = referralOrder ?? orderList.first(where: { $0.orderNumber == orderNumber })?.referralOrder {
|
||||||
let lead = OrderSourceLead(
|
return OrderSourceMapping.selection(from: referral)
|
||||||
key: referral.orderNo,
|
}
|
||||||
displayText: referral.displayText.isEmpty ? referral.orderNo : referral.displayText,
|
return nil
|
||||||
userMobile: referral.userMobile,
|
|
||||||
createdAt: referral.createdAt
|
|
||||||
)
|
|
||||||
return OrderSourceSelection(person: person, lead: lead)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func onOrderSourceSheetOpen(api: OrderAPI) async {
|
func onOrderSourceSheetOpen(api: OrderAPI) async {
|
||||||
orderSourceLeads = [:]
|
orderSourceLeads = [:]
|
||||||
guard !loadingOrderSourcePeople else { return }
|
guard !isLoadingOrderSourcePeople else { return }
|
||||||
loadingOrderSourcePeople = true
|
isLoadingOrderSourcePeople = true
|
||||||
defer { loadingOrderSourcePeople = false }
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isLoadingOrderSourcePeople = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
let storeId = AppStore.shared.currentStoreId
|
let storeId = AppStore.shared.currentStoreId
|
||||||
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
||||||
do {
|
do {
|
||||||
@ -331,15 +382,7 @@ final class OrderListViewModel {
|
|||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let response = try await api.referralOrders(saleUserId: person.key)
|
let response = try await api.referralOrders(saleUserId: person.key)
|
||||||
let leads = response.list.map {
|
orderSourceLeads[person.key] = response.list.map(OrderSourceMapping.lead(from:))
|
||||||
OrderSourceLead(
|
|
||||||
key: $0.displayReferralOrderNo,
|
|
||||||
displayText: $0.displayReferralOrderNo,
|
|
||||||
userMobile: $0.userMobile,
|
|
||||||
createdAt: $0.createdAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
orderSourceLeads[person.key] = leads
|
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
} catch {
|
} catch {
|
||||||
onShowMessage?(error.localizedDescription)
|
onShowMessage?(error.localizedDescription)
|
||||||
|
|||||||
@ -42,6 +42,23 @@ final class ProfileViewModel {
|
|||||||
return name.isEmpty ? "--" : name
|
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 {
|
var currentScenicName: String {
|
||||||
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return name.isEmpty ? "--" : name
|
return name.isEmpty ? "--" : name
|
||||||
|
|||||||
@ -21,6 +21,8 @@ enum OrderTokens {
|
|||||||
static let giftBorder = UIColor(hex: 0xE5E7EB)
|
static let giftBorder = UIColor(hex: 0xE5E7EB)
|
||||||
static let sourceBorder = UIColor(hex: 0xE6EAF0)
|
static let sourceBorder = UIColor(hex: 0xE6EAF0)
|
||||||
static let sourceBackground = UIColor(hex: 0xF9FAFC)
|
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 depositSuccess = UIColor(hex: 0x27AE60)
|
||||||
static let depositSuccessBackground = UIColor(hex: 0xE8F8F0)
|
static let depositSuccessBackground = UIColor(hex: 0xE8F8F0)
|
||||||
static let depositRefunded = UIColor(hex: 0x6B7280)
|
static let depositRefunded = UIColor(hex: 0x6B7280)
|
||||||
|
|||||||
196
suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift
Normal file
196
suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
102
suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift
Normal file
102
suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
595
suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift
Normal file
595
suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift
Normal file
@ -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..<missing).forEach { _ in
|
||||||
|
let spacer = UIView()
|
||||||
|
imageStack.addArrangedSubview(spacer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func imageTapped(_ gesture: UITapGestureRecognizer) {
|
||||||
|
guard let index = gesture.view?.tag else { return }
|
||||||
|
onImageTap?(index, imageURLs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获客订单 Cell。
|
||||||
|
final class AcquisitionOrderCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "AcquisitionOrderCell"
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let typeTag = UILabel()
|
||||||
|
private let orderNumberLabel = UILabel()
|
||||||
|
private let partnerRow = CooperationInfoRowView(label: "合作人员")
|
||||||
|
private let timeRow = CooperationInfoRowView(label: "下单时间")
|
||||||
|
private let customerLabel = UILabel()
|
||||||
|
private let amountLabel = UILabel()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
|
||||||
|
typeTag.font = .systemFont(ofSize: 11)
|
||||||
|
typeTag.textColor = AppColor.primary
|
||||||
|
typeTag.backgroundColor = AppColor.primaryLight
|
||||||
|
typeTag.layer.cornerRadius = AppRadius.xs
|
||||||
|
typeTag.clipsToBounds = true
|
||||||
|
typeTag.textAlignment = .center
|
||||||
|
|
||||||
|
orderNumberLabel.font = .systemFont(ofSize: 13)
|
||||||
|
orderNumberLabel.textColor = AppColor.textSecondary
|
||||||
|
|
||||||
|
customerLabel.font = .systemFont(ofSize: 12)
|
||||||
|
customerLabel.textColor = AppColor.textTertiary
|
||||||
|
|
||||||
|
amountLabel.font = .systemFont(ofSize: 17, weight: .bold)
|
||||||
|
amountLabel.textColor = AppColor.primary
|
||||||
|
amountLabel.textAlignment = .right
|
||||||
|
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(titleLabel)
|
||||||
|
cardView.addSubview(typeTag)
|
||||||
|
cardView.addSubview(orderNumberLabel)
|
||||||
|
cardView.addSubview(partnerRow)
|
||||||
|
cardView.addSubview(timeRow)
|
||||||
|
cardView.addSubview(customerLabel)
|
||||||
|
cardView.addSubview(amountLabel)
|
||||||
|
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md))
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.equalToSuperview().offset(14)
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||||
|
}
|
||||||
|
typeTag.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(10)
|
||||||
|
make.leading.equalToSuperview().offset(14)
|
||||||
|
make.height.equalTo(22)
|
||||||
|
}
|
||||||
|
orderNumberLabel.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(typeTag)
|
||||||
|
make.leading.equalTo(typeTag.snp.trailing).offset(AppSpacing.xs)
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||||
|
}
|
||||||
|
partnerRow.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(typeTag.snp.bottom).offset(10)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(14)
|
||||||
|
}
|
||||||
|
timeRow.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(partnerRow.snp.bottom).offset(10)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(14)
|
||||||
|
}
|
||||||
|
customerLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(timeRow.snp.bottom).offset(10)
|
||||||
|
make.leading.equalToSuperview().offset(14)
|
||||||
|
make.bottom.equalToSuperview().inset(14)
|
||||||
|
}
|
||||||
|
amountLabel.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(customerLabel)
|
||||||
|
make.trailing.equalToSuperview().inset(14)
|
||||||
|
make.leading.greaterThanOrEqualTo(customerLabel.snp.trailing).offset(AppSpacing.xs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(with order: AcquisitionOrderEntity) {
|
||||||
|
titleLabel.text = order.projectName.isEmpty ? "—" : order.projectName
|
||||||
|
if order.orderTypeLabel.isEmpty {
|
||||||
|
typeTag.isHidden = true
|
||||||
|
orderNumberLabel.snp.remakeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(10)
|
||||||
|
make.leading.equalToSuperview().offset(14)
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
typeTag.isHidden = false
|
||||||
|
typeTag.text = " \(order.orderTypeLabel) "
|
||||||
|
orderNumberLabel.snp.remakeConstraints { make in
|
||||||
|
make.centerY.equalTo(typeTag)
|
||||||
|
make.leading.equalTo(typeTag.snp.trailing).offset(AppSpacing.xs)
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orderNumberLabel.text = "订单号 \(order.orderNumber)"
|
||||||
|
partnerRow.setValue(order.displayPartner.isEmpty ? "暂未绑定" : order.displayPartner)
|
||||||
|
timeRow.setValue(order.createdAt.isEmpty ? "—" : order.createdAt)
|
||||||
|
customerLabel.text = "客户 \(order.displayCustomerPhone.isEmpty ? "—" : order.displayCustomerPhone)"
|
||||||
|
amountLabel.text = "¥\(order.displayAmount)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合作获客员 Cell。
|
||||||
|
final class CooperationAcquirerCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "CooperationAcquirerCell"
|
||||||
|
|
||||||
|
var onEditRemark: (() -> 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))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
if !hasInitialized {
|
if !hasInitialized {
|
||||||
@ -361,18 +366,17 @@ final class HomeViewController: BaseViewController {
|
|||||||
|
|
||||||
private func presentScenicDialog() {
|
private func presentScenicDialog() {
|
||||||
let alert = UIAlertController(
|
let alert = UIAlertController(
|
||||||
title: "请选择景区",
|
title: "提示",
|
||||||
message: "使用首页功能前需要先选择当前景区",
|
message: "您还没有选定入驻景区,请先入驻",
|
||||||
preferredStyle: .alert
|
preferredStyle: .alert
|
||||||
)
|
)
|
||||||
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
|
alert.isModalInPresentation = true
|
||||||
self?.viewModel.hideScenicDialog()
|
alert.addAction(UIAlertAction(title: "关闭app", style: .default) { _ in
|
||||||
self?.activeDialog = nil
|
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
|
||||||
})
|
})
|
||||||
alert.addAction(UIAlertAction(title: "去选择", style: .default) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "去入驻", style: .default) { [weak self] _ in
|
||||||
self?.viewModel.hideScenicDialog()
|
|
||||||
self?.activeDialog = nil
|
self?.activeDialog = nil
|
||||||
self?.presentScenicSelection()
|
self?.pushScenicSelection()
|
||||||
})
|
})
|
||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
@ -440,24 +444,24 @@ final class HomeViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func presentScenicSelection() {
|
private func presentScenicSelection() {
|
||||||
let scenicList = AppStore.shared.roleScenicList()
|
pushScenicSelection()
|
||||||
guard !scenicList.isEmpty else {
|
}
|
||||||
showToast("暂无可选景区")
|
|
||||||
return
|
private func pushScenicSelection() {
|
||||||
}
|
navigationController?.setNavigationBarHidden(false, animated: true)
|
||||||
let controller = ScenicSelectionViewController(scenicList: scenicList)
|
let controller = ScenicSelectionViewController()
|
||||||
controller.onSelected = { [weak self] in
|
controller.onSelectionComplete = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
Task {
|
Task {
|
||||||
await self.viewModel.reloadIfNeeded(api: self.homeAPI)
|
self.viewModel.refreshLocalDisplayState()
|
||||||
|
self.viewModel.rebuildCommonMenus()
|
||||||
await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI)
|
await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI)
|
||||||
self.applyViewModel()
|
self.applyViewModel()
|
||||||
await self.evaluateDialogsWithDelay()
|
await self.viewModel.evaluateDialogs(api: self.homeAPI)
|
||||||
|
await MainActor.run { self.presentDialogsIfNeeded() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let nav = UINavigationController(rootViewController: controller)
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
nav.modalPresentationStyle = .pageSheet
|
|
||||||
present(nav, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startCountdownTimer() {
|
private func startCountdownTimer() {
|
||||||
|
|||||||
194
suixinkan/UI/Home/PermissionApplyStatusViewController.swift
Normal file
194
suixinkan/UI/Home/PermissionApplyStatusViewController.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
208
suixinkan/UI/Home/PermissionApplyViewController.swift
Normal file
208
suixinkan/UI/Home/PermissionApplyViewController.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,16 +6,23 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 景区选择页,使用 role-permission 返回的景区列表。
|
/// 景区选择页,对齐 Android `ScenicSelectionScreen`。
|
||||||
final class ScenicSelectionViewController: BaseViewController {
|
final class ScenicSelectionViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
|
||||||
var onSelected: (() -> Void)?
|
var onSelectionComplete: (() -> Void)?
|
||||||
|
|
||||||
private let scenicList: [ScenicInfo]
|
private let viewModel: ScenicSelectionViewModel
|
||||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
private let homeAPI: HomeAPI
|
||||||
|
|
||||||
init(scenicList: [ScenicInfo]) {
|
private let bannerView = ScenicPermissionBannerView()
|
||||||
self.scenicList = scenicList
|
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)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,61 +32,122 @@ final class ScenicSelectionViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "选择景区"
|
title = "景区选择"
|
||||||
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||||
barButtonSystemItem: .close,
|
|
||||||
target: self,
|
|
||||||
action: #selector(closeTapped)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
view.backgroundColor = .white
|
||||||
|
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
tableView.separatorStyle = .none
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
tableView.register(ScenicSpotCell.self, forCellReuseIdentifier: ScenicSpotCell.reuseIdentifier)
|
||||||
view.addSubview(tableView)
|
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
|
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() {
|
override func bindActions() {
|
||||||
dismiss(animated: true)
|
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 {
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
scenicList.count
|
viewModel.spots.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
let cell = tableView.dequeueReusableCell(
|
||||||
var config = cell.defaultContentConfiguration()
|
withIdentifier: ScenicSpotCell.reuseIdentifier,
|
||||||
config.text = scenicList[indexPath.row].name
|
for: indexPath
|
||||||
cell.contentConfiguration = config
|
) as! ScenicSpotCell
|
||||||
cell.accessoryType = scenicList[indexPath.row].id == AppStore.shared.currentScenicId ? .checkmark : .none
|
let spot = viewModel.spots[indexPath.row]
|
||||||
|
cell.apply(spot)
|
||||||
|
cell.onTap = { [weak self] in
|
||||||
|
self?.viewModel.selectSpot(spot)
|
||||||
|
}
|
||||||
return cell
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
252
suixinkan/UI/Home/Views/PermissionApplyViews.swift
Normal file
252
suixinkan/UI/Home/Views/PermissionApplyViews.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
300
suixinkan/UI/Home/Views/ScenicSelectionViews.swift
Normal file
300
suixinkan/UI/Home/Views/ScenicSelectionViews.swift
Normal file
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -136,6 +136,24 @@ final class MainTabBarController: UITabBarController {
|
|||||||
scanHandler.onShowMultiVerifySheet = { [weak self] spots, selected in
|
scanHandler.onShowMultiVerifySheet = { [weak self] spots, selected in
|
||||||
self?.presentMultiVerifySheet(spots: spots, selected: selected)
|
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) {
|
private func showScanToast(_ message: String) {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ struct OrderCardActions {
|
|||||||
var onRefinedChanged: ((Int) -> Void)?
|
var onRefinedChanged: ((Int) -> Void)?
|
||||||
var onHistoricalPhoto: (() -> Void)?
|
var onHistoricalPhoto: (() -> Void)?
|
||||||
var onOrderSourceTap: (() -> Void)?
|
var onOrderSourceTap: (() -> Void)?
|
||||||
|
var onOrderSourceImageTap: (([String], Int) -> Void)?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 摄影师订单卡片,严格对齐 Android `OrderView`。
|
/// 摄影师订单卡片,严格对齐 Android `OrderView`。
|
||||||
@ -34,8 +35,7 @@ final class OrderCardCell: UITableViewCell {
|
|||||||
private let statusBadge = OrderStatusBadgeView()
|
private let statusBadge = OrderStatusBadgeView()
|
||||||
private let createdAtLabel = UILabel()
|
private let createdAtLabel = UILabel()
|
||||||
private let typeChip = OrderTypeChipView()
|
private let typeChip = OrderTypeChipView()
|
||||||
private let orderSourceContainer = UIView()
|
private let orderSourceEntryView = OrderSourceEntryView()
|
||||||
private let orderSourceButton = UIButton(type: .system)
|
|
||||||
private let actionRow = UIStackView()
|
private let actionRow = UIStackView()
|
||||||
private let refundButton = OrderActionButton(title: "退款", style: .destructiveLight)
|
private let refundButton = OrderActionButton(title: "退款", style: .destructiveLight)
|
||||||
private let verifyButton = OrderActionButton(title: "核销订单", style: .primaryFilled)
|
private let verifyButton = OrderActionButton(title: "核销订单", style: .primaryFilled)
|
||||||
@ -79,7 +79,7 @@ final class OrderCardCell: UITableViewCell {
|
|||||||
hideWriteActions: Bool,
|
hideWriteActions: Bool,
|
||||||
actions: OrderCardActions,
|
actions: OrderCardActions,
|
||||||
showOrderSource: Bool,
|
showOrderSource: Bool,
|
||||||
sourceText: String?
|
sourceSelection: OrderSourceSelection?
|
||||||
) {
|
) {
|
||||||
self.actions = actions
|
self.actions = actions
|
||||||
orderNumberLabel.text = "订单号:\(order.orderNumber)"
|
orderNumberLabel.text = "订单号:\(order.orderNumber)"
|
||||||
@ -98,8 +98,10 @@ final class OrderCardCell: UITableViewCell {
|
|||||||
|
|
||||||
uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19
|
uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19
|
||||||
|
|
||||||
orderSourceContainer.isHidden = !showOrderSource
|
orderSourceEntryView.isHidden = !showOrderSource
|
||||||
orderSourceButton.setTitle(sourceText ?? "选择订单来源", for: .normal)
|
if showOrderSource {
|
||||||
|
orderSourceEntryView.apply(selection: sourceSelection)
|
||||||
|
}
|
||||||
|
|
||||||
let showEdit = (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19
|
let showEdit = (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19
|
||||||
editOptionView.isHidden = !showEdit
|
editOptionView.isHidden = !showEdit
|
||||||
@ -149,17 +151,9 @@ final class OrderCardCell: UITableViewCell {
|
|||||||
headerRow.addArrangedSubview(orderNumberLabel)
|
headerRow.addArrangedSubview(orderNumberLabel)
|
||||||
headerRow.addArrangedSubview(statusBadge)
|
headerRow.addArrangedSubview(statusBadge)
|
||||||
|
|
||||||
orderSourceContainer.backgroundColor = OrderTokens.sourceBackground
|
orderSourceEntryView.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside)
|
||||||
orderSourceContainer.layer.cornerRadius = AppRadius.sm
|
orderSourceEntryView.onImageTap = { [weak self] images, index in
|
||||||
orderSourceContainer.layer.borderWidth = 1
|
self?.actions.onOrderSourceImageTap?(images, index)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actionRow.axis = .horizontal
|
actionRow.axis = .horizontal
|
||||||
@ -211,7 +205,7 @@ final class OrderCardCell: UITableViewCell {
|
|||||||
headerRow,
|
headerRow,
|
||||||
createdAtLabel,
|
createdAtLabel,
|
||||||
typeChip,
|
typeChip,
|
||||||
orderSourceContainer,
|
orderSourceEntryView,
|
||||||
actionRow,
|
actionRow,
|
||||||
uploadTaskButton,
|
uploadTaskButton,
|
||||||
editOptionView,
|
editOptionView,
|
||||||
|
|||||||
@ -6,23 +6,58 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 订单来源选择器。
|
/// 订单来源选择 Bottom Sheet,对齐 Android `OrderSourcePicker` 弹层。
|
||||||
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
|
||||||
var onPersonSelected: ((OrderSourcePerson) -> Void)?
|
private enum MenuLevel {
|
||||||
var onLeadSelected: ((OrderSourceSelection) -> Void)?
|
case person
|
||||||
|
case lead
|
||||||
|
}
|
||||||
|
|
||||||
private let people: [OrderSourcePerson]
|
private let orderNumber: String
|
||||||
private let leads: [OrderSourceLead]
|
private let viewModel: OrderListViewModel
|
||||||
private let selectedPerson: OrderSourcePerson?
|
private let onPersonSelected: (OrderSourcePerson) -> Void
|
||||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
private let onLeadBound: (OrderSourceSelection) -> Void
|
||||||
private var showingLeads = false
|
private let onCallPhone: (String) -> Void
|
||||||
|
private let onPreviewImages: ([String], Int) -> Void
|
||||||
|
|
||||||
init(people: [OrderSourcePerson], leads: [OrderSourceLead], selectedPerson: OrderSourcePerson?) {
|
private var level: MenuLevel = .person
|
||||||
self.people = people
|
private var pendingPerson: OrderSourcePerson?
|
||||||
self.leads = leads
|
|
||||||
self.selectedPerson = selectedPerson
|
private let headerStack = UIStackView()
|
||||||
self.showingLeads = !leads.isEmpty
|
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)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,44 +68,210 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
title = showingLeads ? "选择带客单" : "选择获客员"
|
view.backgroundColor = .white
|
||||||
view.backgroundColor = UIColor(hex: 0xF5F5F5)
|
setupUI()
|
||||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped))
|
applyViewModel()
|
||||||
tableView.dataSource = self
|
|
||||||
tableView.delegate = self
|
|
||||||
view.addSubview(tableView)
|
|
||||||
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func closeTapped() {
|
func reloadFromViewModel() {
|
||||||
dismiss(animated: true)
|
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 {
|
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 {
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
let currentSelection = viewModel.orderSourceSelection(
|
||||||
if showingLeads {
|
orderNumber: orderNumber,
|
||||||
let lead = leads[indexPath.row]
|
referralOrder: referralOrder
|
||||||
cell.textLabel?.text = lead.displayText
|
)
|
||||||
cell.detailTextLabel?.text = lead.userMobile
|
switch level {
|
||||||
} else {
|
case .person:
|
||||||
let person = people[indexPath.row]
|
let cell = tableView.dequeueReusableCell(
|
||||||
cell.textLabel?.text = person.name
|
withIdentifier: OrderSourcePersonCell.reuseIdentifier,
|
||||||
cell.detailTextLabel?.text = person.phone
|
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) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
if showingLeads, let person = selectedPerson {
|
guard level == .person else { return }
|
||||||
let lead = leads[indexPath.row]
|
let person = viewModel.orderSourcePeople[indexPath.row]
|
||||||
onLeadSelected?(OrderSourceSelection(person: person, lead: lead))
|
pendingPerson = person
|
||||||
dismiss(animated: true)
|
level = .lead
|
||||||
} else {
|
onPersonSelected(person)
|
||||||
onPersonSelected?(people[indexPath.row])
|
applyViewModel()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
private var hasInitialized = false
|
private var hasInitialized = false
|
||||||
private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin }
|
private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin }
|
||||||
private var pendingOrderSourceNumber: String?
|
private var pendingOrderSourceNumber: String?
|
||||||
private var pendingOrderSourcePerson: OrderSourcePerson?
|
private weak var orderSourcePicker: OrderSourcePickerViewController?
|
||||||
private var pendingGiftOrderNumber: String?
|
private var pendingGiftOrderNumber: String?
|
||||||
private var isFetchingMultiVerifySpots = false
|
private var isFetchingMultiVerifySpots = false
|
||||||
private let locationProvider = OrderLocationProvider()
|
private let locationProvider = OrderLocationProvider()
|
||||||
@ -51,11 +51,17 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
orderListViewModel.onStateChange = { [weak self] in
|
orderListViewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in self?.applyViewModel() }
|
Task { @MainActor in
|
||||||
|
self?.applyViewModel()
|
||||||
|
self?.orderSourcePicker?.reloadFromViewModel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
orderListViewModel.onShowMessage = { [weak self] message in
|
orderListViewModel.onShowMessage = { [weak self] message in
|
||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
|
orderListViewModel.onNavigateBindAcquirer = { [weak self] saleUserId in
|
||||||
|
Task { @MainActor in self?.presentBindAcquirer(saleUserId: saleUserId) }
|
||||||
|
}
|
||||||
depositViewModel.onStateChange = { [weak self] in
|
depositViewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in self?.applyViewModel() }
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
}
|
}
|
||||||
@ -259,6 +265,13 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
present(nav, animated: true)
|
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 {
|
private func fetchMultiVerifySpotsIfNeeded() async {
|
||||||
isFetchingMultiVerifySpots = true
|
isFetchingMultiVerifySpots = true
|
||||||
defer { isFetchingMultiVerifySpots = false }
|
defer { isFetchingMultiVerifySpots = false }
|
||||||
@ -322,7 +335,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
hideWriteActions: orderListViewModel.isScenicAdminReadOnly,
|
hideWriteActions: orderListViewModel.isScenicAdminReadOnly,
|
||||||
actions: makeOrderActions(for: order),
|
actions: makeOrderActions(for: order),
|
||||||
showOrderSource: orderListViewModel.shouldShowOrderSource(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))
|
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: orderListViewModel.shouldLoadMore(at: indexPath.row))
|
||||||
return cell
|
return cell
|
||||||
@ -363,6 +376,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
actions.onOrderSourceTap = { [weak self] in
|
actions.onOrderSourceTap = { [weak self] in
|
||||||
self?.presentOrderSourcePicker(for: order)
|
self?.presentOrderSourcePicker(for: order)
|
||||||
}
|
}
|
||||||
|
actions.onOrderSourceImageTap = { [weak self] images, index in
|
||||||
|
self?.presentImagePreview(images: images, startIndex: index)
|
||||||
|
}
|
||||||
if let travel = order.photoTravel, travel.canGiftRetouch {
|
if let travel = order.photoTravel, travel.canGiftRetouch {
|
||||||
actions.onSendGift = { [weak self] in
|
actions.onSendGift = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@ -451,23 +467,23 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
pendingOrderSourceNumber = order.orderNumber
|
pendingOrderSourceNumber = order.orderNumber
|
||||||
Task {
|
Task {
|
||||||
await orderListViewModel.onOrderSourceSheetOpen(api: orderAPI)
|
await orderListViewModel.onOrderSourceSheetOpen(api: orderAPI)
|
||||||
let controller = OrderSourcePickerViewController(
|
await MainActor.run { [weak self] in
|
||||||
people: orderListViewModel.orderSourcePeople,
|
|
||||||
leads: [],
|
|
||||||
selectedPerson: nil
|
|
||||||
)
|
|
||||||
controller.onPersonSelected = { [weak self] person in
|
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.pendingOrderSourcePerson = person
|
let controller = OrderSourcePickerViewController(
|
||||||
Task {
|
orderNumber: order.orderNumber,
|
||||||
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
|
viewModel: self.orderListViewModel,
|
||||||
let leadsController = OrderSourcePickerViewController(
|
referralOrder: order.referralOrder,
|
||||||
people: self.orderListViewModel.orderSourcePeople,
|
onPersonSelected: { [weak self] person in
|
||||||
leads: self.orderListViewModel.orderSourceLeads[person.key] ?? [],
|
guard let self else { return }
|
||||||
selectedPerson: person
|
Task {
|
||||||
)
|
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
|
||||||
leadsController.onLeadSelected = { [weak self] selection in
|
}
|
||||||
|
},
|
||||||
|
onLeadBound: { [weak self] selection in
|
||||||
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
|
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
|
||||||
|
self.dismiss(animated: true) {
|
||||||
|
self.orderSourcePicker = nil
|
||||||
|
}
|
||||||
Task {
|
Task {
|
||||||
await self.orderListViewModel.bindOrderSource(
|
await self.orderListViewModel.bindOrderSource(
|
||||||
orderNumber: orderNumber,
|
orderNumber: orderNumber,
|
||||||
@ -475,11 +491,44 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
api: self.orderAPI
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
suixinkan/UI/Orders/Views/OrderSourceEntryView.swift
Normal file
112
suixinkan/UI/Orders/Views/OrderSourceEntryView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
suixinkan/UI/Orders/Views/OrderSourceLeadCell.swift
Normal file
106
suixinkan/UI/Orders/Views/OrderSourceLeadCell.swift
Normal file
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
138
suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift
Normal file
138
suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift
Normal file
@ -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..<placeholders).forEach { _ in
|
||||||
|
let spacer = UIView()
|
||||||
|
imageRow.addArrangedSubview(spacer)
|
||||||
|
spacer.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(spacer.snp.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageRow.isHidden = compact && lead.images.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
phoneRow.axis = .horizontal
|
||||||
|
phoneRow.alignment = .center
|
||||||
|
phoneRow.spacing = 10
|
||||||
|
phoneTitleLabel.text = "客户手机号"
|
||||||
|
phoneTitleLabel.font = .systemFont(ofSize: 12)
|
||||||
|
phoneTitleLabel.textColor = AppColor.text666
|
||||||
|
phoneValueLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
phoneValueLabel.addGestureRecognizer(
|
||||||
|
UITapGestureRecognizer(target: self, action: #selector(phoneTapped))
|
||||||
|
)
|
||||||
|
phoneRow.addArrangedSubview(phoneTitleLabel)
|
||||||
|
phoneRow.addArrangedSubview(phoneValueLabel)
|
||||||
|
|
||||||
|
remarkLabel.textColor = AppColor.text333
|
||||||
|
remarkLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
|
imageRow.axis = .horizontal
|
||||||
|
imageRow.spacing = 8
|
||||||
|
imageRow.distribution = .fillEqually
|
||||||
|
|
||||||
|
emptyImageLabel.text = "暂无图片"
|
||||||
|
emptyImageLabel.font = .systemFont(ofSize: 12)
|
||||||
|
emptyImageLabel.textColor = AppColor.text666
|
||||||
|
emptyImageLabel.textAlignment = .center
|
||||||
|
emptyImageLabel.backgroundColor = OrderTokens.sourceLeadPlaceholder
|
||||||
|
emptyImageLabel.layer.cornerRadius = 6
|
||||||
|
emptyImageLabel.clipsToBounds = true
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [phoneRow, remarkLabel, imageRow, emptyImageLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 10
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
emptyImageLabel.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func phoneTapped() {
|
||||||
|
let phone = phoneValueLabel.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !phone.isEmpty else { return }
|
||||||
|
onCallPhone?(phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func imageTapped(_ gesture: UITapGestureRecognizer) {
|
||||||
|
guard let imageView = gesture.view as? UIImageView else { return }
|
||||||
|
onImageTap?(currentImages, imageView.tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
suixinkan/UI/Orders/Views/OrderSourcePersonCell.swift
Normal file
95
suixinkan/UI/Orders/Views/OrderSourcePersonCell.swift
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// OrderSourcePersonCell.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 获客员选择列表 cell,对齐 Android `SourcePersonItem`。
|
||||||
|
final class OrderSourcePersonCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "OrderSourcePersonCell"
|
||||||
|
|
||||||
|
var onCallPhone: ((String) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,10 @@ final class ProfileInfoRowView: UIControl {
|
|||||||
private let divider = UIView()
|
private let divider = UIView()
|
||||||
private let badgeContainer = UIView()
|
private let badgeContainer = UIView()
|
||||||
private var badgeView: ProfileStatusBadgeView?
|
private var badgeView: ProfileStatusBadgeView?
|
||||||
|
private let rightStack = UIStackView()
|
||||||
|
private let stackNameLabel = UILabel()
|
||||||
|
private let stackTypeTag = UILabel()
|
||||||
|
private var rowHeightConstraint: Constraint?
|
||||||
|
|
||||||
var showsDivider = true {
|
var showsDivider = true {
|
||||||
didSet { divider.isHidden = !showsDivider }
|
didSet { divider.isHidden = !showsDivider }
|
||||||
@ -44,6 +48,8 @@ final class ProfileInfoRowView: UIControl {
|
|||||||
accessory: AccessoryStyle = .none,
|
accessory: AccessoryStyle = .none,
|
||||||
badge: ProfileStatusBadgeView.Style? = nil
|
badge: ProfileStatusBadgeView.Style? = nil
|
||||||
) {
|
) {
|
||||||
|
setStackedModeEnabled(false)
|
||||||
|
|
||||||
valueLabel.text = value
|
valueLabel.text = value
|
||||||
valueLabel.textColor = valueColor
|
valueLabel.textColor = valueColor
|
||||||
valueLabel.isHidden = badge != nil
|
valueLabel.isHidden = badge != nil
|
||||||
@ -73,6 +79,84 @@ final class ProfileInfoRowView: UIControl {
|
|||||||
accessoryLabel.isHidden = false
|
accessoryLabel.isHidden = false
|
||||||
chevronView.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() {
|
private func setupUI() {
|
||||||
@ -83,13 +167,36 @@ final class ProfileInfoRowView: UIControl {
|
|||||||
valueLabel.font = .systemFont(ofSize: 14)
|
valueLabel.font = .systemFont(ofSize: 14)
|
||||||
valueLabel.textAlignment = .right
|
valueLabel.textAlignment = .right
|
||||||
valueLabel.numberOfLines = 1
|
valueLabel.numberOfLines = 1
|
||||||
|
valueLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
accessoryLabel.font = .systemFont(ofSize: 14)
|
accessoryLabel.font = .systemFont(ofSize: 14)
|
||||||
accessoryLabel.textColor = AppColor.primary
|
accessoryLabel.textColor = AppColor.primary
|
||||||
|
accessoryLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
accessoryLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
|
||||||
chevronView.image = UIImage(systemName: "chevron.right")
|
chevronView.image = UIImage(systemName: "chevron.right")
|
||||||
chevronView.tintColor = AppColor.text666
|
chevronView.tintColor = AppColor.text666
|
||||||
chevronView.contentMode = .scaleAspectFit
|
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)
|
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
|
||||||
|
|
||||||
@ -98,10 +205,11 @@ final class ProfileInfoRowView: UIControl {
|
|||||||
addSubview(badgeContainer)
|
addSubview(badgeContainer)
|
||||||
addSubview(accessoryLabel)
|
addSubview(accessoryLabel)
|
||||||
addSubview(chevronView)
|
addSubview(chevronView)
|
||||||
|
addSubview(rightStack)
|
||||||
addSubview(divider)
|
addSubview(divider)
|
||||||
|
|
||||||
snp.makeConstraints { make in
|
snp.makeConstraints { make in
|
||||||
make.height.equalTo(52)
|
rowHeightConstraint = make.height.equalTo(52).constraint
|
||||||
}
|
}
|
||||||
titleLabel.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview()
|
make.leading.equalToSuperview()
|
||||||
@ -117,16 +225,16 @@ final class ProfileInfoRowView: UIControl {
|
|||||||
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
||||||
make.centerY.equalToSuperview()
|
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.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
|
||||||
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
|
||||||
make.centerY.equalToSuperview()
|
|
||||||
}
|
}
|
||||||
badgeContainer.snp.makeConstraints { make in
|
stackTypeTag.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
make.height.equalTo(22)
|
||||||
make.centerY.equalToSuperview()
|
make.width.greaterThanOrEqualTo(56)
|
||||||
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
|
|
||||||
}
|
}
|
||||||
|
updateContentConstraints(for: .none)
|
||||||
divider.snp.makeConstraints { make in
|
divider.snp.makeConstraints { make in
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
make.height.equalTo(0.5)
|
make.height.equalTo(0.5)
|
||||||
|
|||||||
@ -31,6 +31,7 @@ final class ProfileViewController: BaseViewController {
|
|||||||
private let statusRow = ProfileInfoRowView(title: "账号状态")
|
private let statusRow = ProfileInfoRowView(title: "账号状态")
|
||||||
private let scenicRow = ProfileInfoRowView(title: "当前景区")
|
private let scenicRow = ProfileInfoRowView(title: "当前景区")
|
||||||
private let logoutButton = UIButton(type: .system)
|
private let logoutButton = UIButton(type: .system)
|
||||||
|
private var hasLoadedProfileOnce = false
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "我的"
|
title = "我的"
|
||||||
@ -101,7 +102,13 @@ final class ProfileViewController: BaseViewController {
|
|||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
Task { await reloadProfile(showGlobalLoading: true) }
|
let showLoading = !hasLoadedProfileOnce
|
||||||
|
Task {
|
||||||
|
await reloadProfile(showGlobalLoading: showLoading)
|
||||||
|
if showLoading {
|
||||||
|
hasLoadedProfileOnce = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private let headerContainer = UIView()
|
private let headerContainer = UIView()
|
||||||
@ -194,9 +201,10 @@ final class ProfileViewController: BaseViewController {
|
|||||||
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
||||||
|
|
||||||
nameRow.configure(value: viewModel.displayRealName)
|
nameRow.configure(value: viewModel.displayRealName)
|
||||||
accountRow.configure(
|
accountRow.configureAccountInfo(
|
||||||
value: viewModel.accountDisplayName,
|
storeName: viewModel.accountDisplayName,
|
||||||
accessory: .action("切换账号")
|
accountTypeLabel: viewModel.accountTypeLabel,
|
||||||
|
isStoreAccount: viewModel.isStoreAccount
|
||||||
)
|
)
|
||||||
phoneRow.configure(value: viewModel.displayPhone)
|
phoneRow.configure(value: viewModel.displayPhone)
|
||||||
|
|
||||||
|
|||||||
86
suixinkanTests/BindAcquirerViewModelTests.swift
Normal file
86
suixinkanTests/BindAcquirerViewModelTests.swift
Normal file
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
88
suixinkanTests/CooperationAcquirerViewModelTests.swift
Normal file
88
suixinkanTests/CooperationAcquirerViewModelTests.swift
Normal file
@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
63
suixinkanTests/CooperationOrderListViewModelTests.swift
Normal file
63
suixinkanTests/CooperationOrderListViewModelTests.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
68
suixinkanTests/CooperationOrderModelsTests.swift
Normal file
68
suixinkanTests/CooperationOrderModelsTests.swift
Normal file
@ -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%(固定)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -148,4 +148,27 @@ final class HomeViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"])
|
XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"])
|
||||||
XCTAssertEqual(session.requests.count, 1)
|
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, "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -166,4 +166,69 @@ final class OrderListViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(list[1].photoTravel?.orderPhotoNum, 6)
|
XCTAssertEqual(list[1].photoTravel?.orderPhotoNum, 6)
|
||||||
XCTAssertEqual(list[1].photoTravel?.needCheckIn, true)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
suixinkanTests/PermissionApplyStatusViewModelTests.swift
Normal file
56
suixinkanTests/PermissionApplyStatusViewModelTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
85
suixinkanTests/PermissionApplyViewModelTests.swift
Normal file
85
suixinkanTests/PermissionApplyViewModelTests.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() {
|
func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() {
|
||||||
AppStore.shared.userName = "全局昵称"
|
AppStore.shared.userName = "全局昵称"
|
||||||
AppStore.shared.avatar = "https://cdn.example.com/new.jpg"
|
AppStore.shared.avatar = "https://cdn.example.com/new.jpg"
|
||||||
|
|||||||
28
suixinkanTests/ScenicDistanceCalculatorTests.swift
Normal file
28
suixinkanTests/ScenicDistanceCalculatorTests.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
102
suixinkanTests/ScenicSelectionViewModelTests.swift
Normal file
102
suixinkanTests/ScenicSelectionViewModelTests.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user