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)
|
||||
return
|
||||
}
|
||||
showDeveloping(from: viewController)
|
||||
viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true)
|
||||
case "task_management", "task_management_editor":
|
||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
default:
|
||||
|
||||
@ -212,9 +212,17 @@ final class HomeViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新景区名与角色展示状态。
|
||||
/// 刷新景区名与角色展示状态,对齐 Android `initCurrentScenicName`。
|
||||
func refreshLocalDisplayState() {
|
||||
currentScenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let scenicId = appStore.currentScenicId
|
||||
let scenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if scenicId > 0, !scenicName.isEmpty {
|
||||
currentScenicName = scenicName
|
||||
} else {
|
||||
currentScenicName = ""
|
||||
appStore.currentScenicId = 0
|
||||
appStore.currentScenicName = ""
|
||||
}
|
||||
currentAppRole = appStore.currentAppRole
|
||||
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
|
||||
@ -789,10 +789,19 @@ struct CooperativeSalerEntity: Decodable, Equatable {
|
||||
let photogRemarkName: String
|
||||
let phone: String
|
||||
let salerPhone: String
|
||||
let commissionRate: Int
|
||||
let commissionRateLabel: String
|
||||
let bindTime: String
|
||||
let boundAt: String
|
||||
|
||||
var displayId: Int { saleUserId }
|
||||
var displayName: String { OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) }
|
||||
var displayPhone: String { salerPhone.isEmpty ? phone : salerPhone }
|
||||
var displayBindTime: String { bindTime.isEmpty ? boundAt : bindTime }
|
||||
var displayCommissionRate: String {
|
||||
if !commissionRateLabel.isEmpty { return commissionRateLabel }
|
||||
return "\(commissionRate)%"
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case saleUserId = "sale_user_id"
|
||||
@ -802,6 +811,67 @@ struct CooperativeSalerEntity: Decodable, Equatable {
|
||||
case photogRemarkName = "photog_remark_name"
|
||||
case phone
|
||||
case salerPhone = "saler_phone"
|
||||
case commissionRate = "commission_rate"
|
||||
case commissionRateLabel = "commission_rate_label"
|
||||
case bindTime = "bind_time"
|
||||
case boundAt = "bound_at"
|
||||
}
|
||||
|
||||
init(
|
||||
saleUserId: Int = 0,
|
||||
bindingId: Int = 0,
|
||||
name: String = "",
|
||||
salerName: String = "",
|
||||
photogRemarkName: String = "",
|
||||
phone: String = "",
|
||||
salerPhone: String = "",
|
||||
commissionRate: Int = 0,
|
||||
commissionRateLabel: String = "",
|
||||
bindTime: String = "",
|
||||
boundAt: String = ""
|
||||
) {
|
||||
self.saleUserId = saleUserId
|
||||
self.bindingId = bindingId
|
||||
self.name = name
|
||||
self.salerName = salerName
|
||||
self.photogRemarkName = photogRemarkName
|
||||
self.phone = phone
|
||||
self.salerPhone = salerPhone
|
||||
self.commissionRate = commissionRate
|
||||
self.commissionRateLabel = commissionRateLabel
|
||||
self.bindTime = bindTime
|
||||
self.boundAt = boundAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
saleUserId = try container.decodeIfPresent(Int.self, forKey: .saleUserId) ?? 0
|
||||
bindingId = try container.decodeIfPresent(Int.self, forKey: .bindingId) ?? 0
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
salerName = try container.decodeIfPresent(String.self, forKey: .salerName) ?? ""
|
||||
photogRemarkName = try container.decodeIfPresent(String.self, forKey: .photogRemarkName) ?? ""
|
||||
phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? ""
|
||||
salerPhone = try container.decodeIfPresent(String.self, forKey: .salerPhone) ?? ""
|
||||
commissionRate = try container.decodeIfPresent(Int.self, forKey: .commissionRate) ?? 0
|
||||
commissionRateLabel = try container.decodeIfPresent(String.self, forKey: .commissionRateLabel) ?? ""
|
||||
bindTime = try container.decodeIfPresent(String.self, forKey: .bindTime) ?? ""
|
||||
boundAt = try container.decodeIfPresent(String.self, forKey: .boundAt) ?? ""
|
||||
}
|
||||
|
||||
func withRemarkName(_ remark: String) -> CooperativeSalerEntity {
|
||||
CooperativeSalerEntity(
|
||||
saleUserId: saleUserId,
|
||||
bindingId: bindingId,
|
||||
name: name,
|
||||
salerName: salerName,
|
||||
photogRemarkName: remark,
|
||||
phone: phone,
|
||||
salerPhone: salerPhone,
|
||||
commissionRate: commissionRate,
|
||||
commissionRateLabel: commissionRateLabel,
|
||||
bindTime: bindTime,
|
||||
boundAt: boundAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -819,6 +889,7 @@ struct ReferralOrderEntity: Decodable, Equatable {
|
||||
let createdAt: String
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
let images: [String]
|
||||
|
||||
var displayReferralOrderNo: String { referralOrderNo.isEmpty ? orderNo : referralOrderNo }
|
||||
|
||||
@ -831,6 +902,20 @@ struct ReferralOrderEntity: Decodable, Equatable {
|
||||
case createdAt = "created_at"
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
case images
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||
referralOrderNo = try container.decodeIfPresent(String.self, forKey: .referralOrderNo) ?? ""
|
||||
orderNo = try container.decodeIfPresent(String.self, forKey: .orderNo) ?? ""
|
||||
userMobile = try container.decodeIfPresent(String.self, forKey: .userMobile) ?? ""
|
||||
remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? ""
|
||||
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
|
||||
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
|
||||
statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) ?? ""
|
||||
images = (try? container.decode([String].self, forKey: .images)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
|
||||
var onShowWriteOff: ((WriteOffOrderResponse) -> Void)?
|
||||
var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)?
|
||||
var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)?
|
||||
var onNavigateBindAcquirer: ((Int) -> Void)?
|
||||
|
||||
private let api: OrderAPI
|
||||
private let locationManager = CLLocationManager()
|
||||
@ -64,7 +65,7 @@ final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
|
||||
await fetchSpotsForMultiVerify(pending: pending)
|
||||
case SaleUserQrParser.methodSaleUserBind:
|
||||
if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 {
|
||||
onShowMessage?("获客员绑定功能待接入")
|
||||
onNavigateBindAcquirer?(id)
|
||||
} else {
|
||||
onShowMessage?("请扫描正确的获客员二维码")
|
||||
}
|
||||
|
||||
@ -20,9 +20,59 @@ struct OrderSourcePerson: Equatable {
|
||||
|
||||
struct OrderSourceLead: Equatable {
|
||||
let key: String
|
||||
let displayText: String
|
||||
let userMobile: String
|
||||
let createdAt: String
|
||||
let userPhone: String
|
||||
let remark: String
|
||||
let images: [String]
|
||||
}
|
||||
|
||||
enum OrderSourceMapping {
|
||||
|
||||
/// 将 API 带客单实体映射为订单来源选择项,对齐 Android `toOrderSourceLead()`。
|
||||
static func lead(from entity: ReferralOrderEntity) -> OrderSourceLead {
|
||||
let orderNo = entity.displayReferralOrderNo
|
||||
let remark: String
|
||||
if !entity.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
remark = entity.remark
|
||||
} else if !entity.statusLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
remark = entity.statusLabel
|
||||
} else {
|
||||
remark = "带客单 \(orderNo)"
|
||||
}
|
||||
return OrderSourceLead(
|
||||
key: orderNo,
|
||||
userPhone: entity.userMobile,
|
||||
remark: remark,
|
||||
images: entity.images
|
||||
)
|
||||
}
|
||||
|
||||
/// 将已绑定带客单实体映射为订单来源选择项,对齐 Android `toOrderSourceSelection()`。
|
||||
static func selection(from referral: BoundReferralOrderEntity) -> OrderSourceSelection? {
|
||||
let referralNo = referral.orderNo.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !referralNo.isEmpty else { return nil }
|
||||
|
||||
let personName = referral.displaySalerName.isEmpty ? "—" : referral.displaySalerName
|
||||
let person = OrderSourcePerson(
|
||||
key: referral.saleUserId > 0 ? String(referral.saleUserId) : "",
|
||||
name: personName,
|
||||
phone: referral.salerPhone
|
||||
)
|
||||
let remark: String
|
||||
if !referral.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
remark = referral.remark
|
||||
} else if !referral.displayText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
remark = referral.displayText
|
||||
} else {
|
||||
remark = "带客单号 \(referralNo)"
|
||||
}
|
||||
let lead = OrderSourceLead(
|
||||
key: referralNo,
|
||||
userPhone: referral.userMobile,
|
||||
remark: remark,
|
||||
images: []
|
||||
)
|
||||
return OrderSourceSelection(person: person, lead: lead)
|
||||
}
|
||||
}
|
||||
|
||||
struct OrderSourceSelection: Equatable {
|
||||
@ -54,13 +104,14 @@ final class OrderListViewModel {
|
||||
private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:]
|
||||
private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
||||
private(set) var loadingOrderSourceLeadsFor: String?
|
||||
private(set) var isLoadingOrderSourcePeople = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onNavigateBindAcquirer: ((Int) -> Void)?
|
||||
|
||||
private var currentPage = 1
|
||||
private var totalCount = 0
|
||||
private var loadingOrderSourcePeople = false
|
||||
|
||||
private let apiDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
@ -188,7 +239,7 @@ final class OrderListViewModel {
|
||||
return
|
||||
case SaleUserQrParser.methodSaleUserBind:
|
||||
if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 {
|
||||
onShowMessage?("获客员绑定功能待接入")
|
||||
onNavigateBindAcquirer?(saleUserId)
|
||||
} else {
|
||||
onShowMessage?("请扫描正确的获客员二维码")
|
||||
}
|
||||
@ -287,27 +338,27 @@ final class OrderListViewModel {
|
||||
}
|
||||
|
||||
func orderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
|
||||
if let selection = orderSourceSelections[order.orderNumber] { return selection }
|
||||
guard let referral = order.referralOrder else { return nil }
|
||||
let person = OrderSourcePerson(
|
||||
key: String(referral.saleUserId),
|
||||
name: referral.displaySalerName,
|
||||
phone: referral.salerPhone
|
||||
)
|
||||
let lead = OrderSourceLead(
|
||||
key: referral.orderNo,
|
||||
displayText: referral.displayText.isEmpty ? referral.orderNo : referral.displayText,
|
||||
userMobile: referral.userMobile,
|
||||
createdAt: referral.createdAt
|
||||
)
|
||||
return OrderSourceSelection(person: person, lead: lead)
|
||||
orderSourceSelection(orderNumber: order.orderNumber, referralOrder: order.referralOrder)
|
||||
}
|
||||
|
||||
/// 按订单号读取当前绑定状态,供订单来源 Sheet 使用。
|
||||
func orderSourceSelection(orderNumber: String, referralOrder: BoundReferralOrderEntity? = nil) -> OrderSourceSelection? {
|
||||
if let selection = orderSourceSelections[orderNumber] { return selection }
|
||||
if let referral = referralOrder ?? orderList.first(where: { $0.orderNumber == orderNumber })?.referralOrder {
|
||||
return OrderSourceMapping.selection(from: referral)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func onOrderSourceSheetOpen(api: OrderAPI) async {
|
||||
orderSourceLeads = [:]
|
||||
guard !loadingOrderSourcePeople else { return }
|
||||
loadingOrderSourcePeople = true
|
||||
defer { loadingOrderSourcePeople = false }
|
||||
guard !isLoadingOrderSourcePeople else { return }
|
||||
isLoadingOrderSourcePeople = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoadingOrderSourcePeople = false
|
||||
notifyStateChange()
|
||||
}
|
||||
let storeId = AppStore.shared.currentStoreId
|
||||
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
||||
do {
|
||||
@ -331,15 +382,7 @@ final class OrderListViewModel {
|
||||
}
|
||||
do {
|
||||
let response = try await api.referralOrders(saleUserId: person.key)
|
||||
let leads = response.list.map {
|
||||
OrderSourceLead(
|
||||
key: $0.displayReferralOrderNo,
|
||||
displayText: $0.displayReferralOrderNo,
|
||||
userMobile: $0.userMobile,
|
||||
createdAt: $0.createdAt
|
||||
)
|
||||
}
|
||||
orderSourceLeads[person.key] = leads
|
||||
orderSourceLeads[person.key] = response.list.map(OrderSourceMapping.lead(from:))
|
||||
notifyStateChange()
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
|
||||
@ -42,6 +42,23 @@ final class ProfileViewModel {
|
||||
return name.isEmpty ? "--" : name
|
||||
}
|
||||
|
||||
/// 当前账号类型文案,如「门店账号」「景区账号」。
|
||||
var accountTypeLabel: String {
|
||||
let type = AppStore.shared.accountType.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if type == V9StoreUser.accountTypeValue {
|
||||
return "门店账号"
|
||||
}
|
||||
if type == V9ScenicUser.accountTypeValue {
|
||||
return "景区账号"
|
||||
}
|
||||
return "--"
|
||||
}
|
||||
|
||||
/// 当前是否为门店账号。
|
||||
var isStoreAccount: Bool {
|
||||
AppStore.shared.accountType == V9StoreUser.accountTypeValue
|
||||
}
|
||||
|
||||
var currentScenicName: String {
|
||||
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
|
||||
@ -21,6 +21,8 @@ enum OrderTokens {
|
||||
static let giftBorder = UIColor(hex: 0xE5E7EB)
|
||||
static let sourceBorder = UIColor(hex: 0xE6EAF0)
|
||||
static let sourceBackground = UIColor(hex: 0xF9FAFC)
|
||||
static let sourceDivider = UIColor(hex: 0xE9EDF3)
|
||||
static let sourceLeadPlaceholder = UIColor(hex: 0xF3F5F8)
|
||||
static let depositSuccess = UIColor(hex: 0x27AE60)
|
||||
static let depositSuccessBackground = UIColor(hex: 0xE8F8F0)
|
||||
static let depositRefunded = UIColor(hex: 0x6B7280)
|
||||
|
||||
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) {
|
||||
super.viewDidAppear(animated)
|
||||
if !hasInitialized {
|
||||
@ -361,18 +366,17 @@ final class HomeViewController: BaseViewController {
|
||||
|
||||
private func presentScenicDialog() {
|
||||
let alert = UIAlertController(
|
||||
title: "请选择景区",
|
||||
message: "使用首页功能前需要先选择当前景区",
|
||||
title: "提示",
|
||||
message: "您还没有选定入驻景区,请先入驻",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.hideScenicDialog()
|
||||
self?.activeDialog = nil
|
||||
alert.isModalInPresentation = true
|
||||
alert.addAction(UIAlertAction(title: "关闭app", style: .default) { _ in
|
||||
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "去选择", style: .default) { [weak self] _ in
|
||||
self?.viewModel.hideScenicDialog()
|
||||
alert.addAction(UIAlertAction(title: "去入驻", style: .default) { [weak self] _ in
|
||||
self?.activeDialog = nil
|
||||
self?.presentScenicSelection()
|
||||
self?.pushScenicSelection()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
@ -440,24 +444,24 @@ final class HomeViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func presentScenicSelection() {
|
||||
let scenicList = AppStore.shared.roleScenicList()
|
||||
guard !scenicList.isEmpty else {
|
||||
showToast("暂无可选景区")
|
||||
return
|
||||
pushScenicSelection()
|
||||
}
|
||||
let controller = ScenicSelectionViewController(scenicList: scenicList)
|
||||
controller.onSelected = { [weak self] in
|
||||
|
||||
private func pushScenicSelection() {
|
||||
navigationController?.setNavigationBarHidden(false, animated: true)
|
||||
let controller = ScenicSelectionViewController()
|
||||
controller.onSelectionComplete = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.viewModel.reloadIfNeeded(api: self.homeAPI)
|
||||
self.viewModel.refreshLocalDisplayState()
|
||||
self.viewModel.rebuildCommonMenus()
|
||||
await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI)
|
||||
self.applyViewModel()
|
||||
await self.evaluateDialogsWithDelay()
|
||||
await self.viewModel.evaluateDialogs(api: self.homeAPI)
|
||||
await MainActor.run { self.presentDialogsIfNeeded() }
|
||||
}
|
||||
}
|
||||
let nav = UINavigationController(rootViewController: controller)
|
||||
nav.modalPresentationStyle = .pageSheet
|
||||
present(nav, animated: true)
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
|
||||
private func startCountdownTimer() {
|
||||
|
||||
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 UIKit
|
||||
|
||||
/// 景区选择页,使用 role-permission 返回的景区列表。
|
||||
final class ScenicSelectionViewController: BaseViewController {
|
||||
/// 景区选择页,对齐 Android `ScenicSelectionScreen`。
|
||||
final class ScenicSelectionViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
var onSelected: (() -> Void)?
|
||||
var onSelectionComplete: (() -> Void)?
|
||||
|
||||
private let scenicList: [ScenicInfo]
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
private let viewModel: ScenicSelectionViewModel
|
||||
private let homeAPI: HomeAPI
|
||||
|
||||
init(scenicList: [ScenicInfo]) {
|
||||
self.scenicList = scenicList
|
||||
private let bannerView = ScenicPermissionBannerView()
|
||||
private let searchBar = ScenicSelectionSearchBar()
|
||||
private let locationBar = ScenicCurrentLocationBar()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
|
||||
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) {
|
||||
self.viewModel = viewModel
|
||||
self.homeAPI = homeAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -25,61 +32,122 @@ final class ScenicSelectionViewController: BaseViewController {
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "选择景区"
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||
barButtonSystemItem: .close,
|
||||
target: self,
|
||||
action: #selector(closeTapped)
|
||||
)
|
||||
title = "景区选择"
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
view.backgroundColor = .white
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
||||
tableView.register(ScenicSpotCell.self, forCellReuseIdentifier: ScenicSpotCell.reuseIdentifier)
|
||||
tableView.keyboardDismissMode = .onDrag
|
||||
|
||||
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)
|
||||
|
||||
override func setupConstraints() {
|
||||
headerStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
make.top.equalTo(headerStack.snp.bottom)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onSelectionComplete = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.onSelectionComplete?()
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
viewModel.onNavigateApply = { [weak self] in
|
||||
Task { @MainActor in self?.pushPermissionApply() }
|
||||
}
|
||||
viewModel.onNavigateApplyStatus = { [weak self] applyCode in
|
||||
Task { @MainActor in self?.pushPermissionApplyStatus(applyCode: applyCode) }
|
||||
}
|
||||
|
||||
bannerView.onApplyNow = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.onApplyNow(api: self.homeAPI) }
|
||||
}
|
||||
searchBar.onSearchTextChange = { [weak self] query in
|
||||
self?.viewModel.onSearchQueryChange(query)
|
||||
}
|
||||
locationBar.onRelocate = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.startLocation() }
|
||||
}
|
||||
|
||||
viewModel.loadFromLocal()
|
||||
Task { await viewModel.startLocation() }
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
locationBar.apply(locationText: viewModel.currentLocationText)
|
||||
emptyLabel.isHidden = !viewModel.spots.isEmpty
|
||||
tableView.reloadData()
|
||||
if viewModel.isLoading {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicSelectionViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
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
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
scenicList.count
|
||||
viewModel.spots.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
||||
var config = cell.defaultContentConfiguration()
|
||||
config.text = scenicList[indexPath.row].name
|
||||
cell.contentConfiguration = config
|
||||
cell.accessoryType = scenicList[indexPath.row].id == AppStore.shared.currentScenicId ? .checkmark : .none
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: ScenicSpotCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! ScenicSpotCell
|
||||
let spot = viewModel.spots[indexPath.row]
|
||||
cell.apply(spot)
|
||||
cell.onTap = { [weak self] in
|
||||
self?.viewModel.selectSpot(spot)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let scenic = scenicList[indexPath.row]
|
||||
AppStore.shared.currentScenicId = scenic.id
|
||||
AppStore.shared.currentScenicName = scenic.name
|
||||
NotificationCenter.default.post(
|
||||
name: NotificationName.scenicDidChange,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
NotificationUserInfoKey.scenicId: scenic.id,
|
||||
NotificationUserInfoKey.scenicName: scenic.name,
|
||||
]
|
||||
)
|
||||
onSelected?()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
|
||||
@ -20,6 +20,7 @@ struct OrderCardActions {
|
||||
var onRefinedChanged: ((Int) -> Void)?
|
||||
var onHistoricalPhoto: (() -> Void)?
|
||||
var onOrderSourceTap: (() -> Void)?
|
||||
var onOrderSourceImageTap: (([String], Int) -> Void)?
|
||||
}
|
||||
|
||||
/// 摄影师订单卡片,严格对齐 Android `OrderView`。
|
||||
@ -34,8 +35,7 @@ final class OrderCardCell: UITableViewCell {
|
||||
private let statusBadge = OrderStatusBadgeView()
|
||||
private let createdAtLabel = UILabel()
|
||||
private let typeChip = OrderTypeChipView()
|
||||
private let orderSourceContainer = UIView()
|
||||
private let orderSourceButton = UIButton(type: .system)
|
||||
private let orderSourceEntryView = OrderSourceEntryView()
|
||||
private let actionRow = UIStackView()
|
||||
private let refundButton = OrderActionButton(title: "退款", style: .destructiveLight)
|
||||
private let verifyButton = OrderActionButton(title: "核销订单", style: .primaryFilled)
|
||||
@ -79,7 +79,7 @@ final class OrderCardCell: UITableViewCell {
|
||||
hideWriteActions: Bool,
|
||||
actions: OrderCardActions,
|
||||
showOrderSource: Bool,
|
||||
sourceText: String?
|
||||
sourceSelection: OrderSourceSelection?
|
||||
) {
|
||||
self.actions = actions
|
||||
orderNumberLabel.text = "订单号:\(order.orderNumber)"
|
||||
@ -98,8 +98,10 @@ final class OrderCardCell: UITableViewCell {
|
||||
|
||||
uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19
|
||||
|
||||
orderSourceContainer.isHidden = !showOrderSource
|
||||
orderSourceButton.setTitle(sourceText ?? "选择订单来源", for: .normal)
|
||||
orderSourceEntryView.isHidden = !showOrderSource
|
||||
if showOrderSource {
|
||||
orderSourceEntryView.apply(selection: sourceSelection)
|
||||
}
|
||||
|
||||
let showEdit = (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19
|
||||
editOptionView.isHidden = !showEdit
|
||||
@ -149,17 +151,9 @@ final class OrderCardCell: UITableViewCell {
|
||||
headerRow.addArrangedSubview(orderNumberLabel)
|
||||
headerRow.addArrangedSubview(statusBadge)
|
||||
|
||||
orderSourceContainer.backgroundColor = OrderTokens.sourceBackground
|
||||
orderSourceContainer.layer.cornerRadius = AppRadius.sm
|
||||
orderSourceContainer.layer.borderWidth = 1
|
||||
orderSourceContainer.layer.borderColor = OrderTokens.sourceBorder.cgColor
|
||||
orderSourceButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
orderSourceButton.titleLabel?.font = .app(.body)
|
||||
orderSourceButton.contentHorizontalAlignment = .leading
|
||||
orderSourceButton.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside)
|
||||
orderSourceContainer.addSubview(orderSourceButton)
|
||||
orderSourceButton.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.sm)
|
||||
orderSourceEntryView.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside)
|
||||
orderSourceEntryView.onImageTap = { [weak self] images, index in
|
||||
self?.actions.onOrderSourceImageTap?(images, index)
|
||||
}
|
||||
|
||||
actionRow.axis = .horizontal
|
||||
@ -211,7 +205,7 @@ final class OrderCardCell: UITableViewCell {
|
||||
headerRow,
|
||||
createdAtLabel,
|
||||
typeChip,
|
||||
orderSourceContainer,
|
||||
orderSourceEntryView,
|
||||
actionRow,
|
||||
uploadTaskButton,
|
||||
editOptionView,
|
||||
|
||||
@ -6,23 +6,58 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 订单来源选择器。
|
||||
/// 订单来源选择 Bottom Sheet,对齐 Android `OrderSourcePicker` 弹层。
|
||||
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||
|
||||
var onPersonSelected: ((OrderSourcePerson) -> Void)?
|
||||
var onLeadSelected: ((OrderSourceSelection) -> Void)?
|
||||
private enum MenuLevel {
|
||||
case person
|
||||
case lead
|
||||
}
|
||||
|
||||
private let people: [OrderSourcePerson]
|
||||
private let leads: [OrderSourceLead]
|
||||
private let selectedPerson: OrderSourcePerson?
|
||||
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
private var showingLeads = false
|
||||
private let orderNumber: String
|
||||
private let viewModel: OrderListViewModel
|
||||
private let onPersonSelected: (OrderSourcePerson) -> Void
|
||||
private let onLeadBound: (OrderSourceSelection) -> Void
|
||||
private let onCallPhone: (String) -> Void
|
||||
private let onPreviewImages: ([String], Int) -> Void
|
||||
|
||||
init(people: [OrderSourcePerson], leads: [OrderSourceLead], selectedPerson: OrderSourcePerson?) {
|
||||
self.people = people
|
||||
self.leads = leads
|
||||
self.selectedPerson = selectedPerson
|
||||
self.showingLeads = !leads.isEmpty
|
||||
private var level: MenuLevel = .person
|
||||
private var pendingPerson: OrderSourcePerson?
|
||||
|
||||
private let headerStack = UIStackView()
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let leadHeaderRow = UIStackView()
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let leadTitleLabel = UILabel()
|
||||
private let leadPhoneButton = UIButton(type: .system)
|
||||
private let leadCallButton = UIButton(type: .system)
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let emptyLabel = UILabel()
|
||||
|
||||
private var referralOrder: BoundReferralOrderEntity?
|
||||
|
||||
init(
|
||||
orderNumber: String,
|
||||
viewModel: OrderListViewModel,
|
||||
referralOrder: BoundReferralOrderEntity?,
|
||||
onPersonSelected: @escaping (OrderSourcePerson) -> Void,
|
||||
onLeadBound: @escaping (OrderSourceSelection) -> Void,
|
||||
onCallPhone: @escaping (String) -> Void,
|
||||
onPreviewImages: @escaping ([String], Int) -> Void
|
||||
) {
|
||||
self.orderNumber = orderNumber
|
||||
self.viewModel = viewModel
|
||||
self.onPersonSelected = onPersonSelected
|
||||
self.onLeadBound = onLeadBound
|
||||
self.onCallPhone = onCallPhone
|
||||
self.onPreviewImages = onPreviewImages
|
||||
self.referralOrder = referralOrder
|
||||
self.pendingPerson = viewModel.orderSourceSelection(
|
||||
orderNumber: orderNumber,
|
||||
referralOrder: referralOrder
|
||||
)?.person
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -33,44 +68,210 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = showingLeads ? "选择带客单" : "选择获客员"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped))
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
view.backgroundColor = .white
|
||||
setupUI()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
func reloadFromViewModel() {
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = AppColor.text333
|
||||
titleLabel.text = "选择获客员"
|
||||
|
||||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||
subtitleLabel.textColor = AppColor.text666
|
||||
subtitleLabel.text = "选择人员后,再选择该人员创建的带客单"
|
||||
subtitleLabel.numberOfLines = 0
|
||||
|
||||
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
backButton.tintColor = AppColor.text333
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
|
||||
leadTitleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
leadTitleLabel.textColor = AppColor.text333
|
||||
leadTitleLabel.numberOfLines = 2
|
||||
|
||||
leadPhoneButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
leadPhoneButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
leadPhoneButton.contentHorizontalAlignment = .leading
|
||||
leadPhoneButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside)
|
||||
|
||||
leadCallButton.backgroundColor = OrderTokens.phoneIconBackground
|
||||
leadCallButton.layer.cornerRadius = 16
|
||||
leadCallButton.tintColor = AppColor.primary
|
||||
leadCallButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||
leadCallButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside)
|
||||
|
||||
leadHeaderRow.axis = .horizontal
|
||||
leadHeaderRow.alignment = .top
|
||||
leadHeaderRow.spacing = 10
|
||||
leadHeaderRow.isHidden = true
|
||||
let leadTextStack = UIStackView(arrangedSubviews: [leadTitleLabel, leadPhoneButton])
|
||||
leadTextStack.axis = .vertical
|
||||
leadTextStack.spacing = 3
|
||||
leadHeaderRow.addArrangedSubview(backButton)
|
||||
leadHeaderRow.addArrangedSubview(leadTextStack)
|
||||
leadHeaderRow.addArrangedSubview(leadCallButton)
|
||||
|
||||
headerStack.axis = .vertical
|
||||
headerStack.spacing = 5
|
||||
headerStack.addArrangedSubview(titleLabel)
|
||||
headerStack.addArrangedSubview(subtitleLabel)
|
||||
headerStack.addArrangedSubview(leadHeaderRow)
|
||||
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = .white
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(OrderSourcePersonCell.self, forCellReuseIdentifier: OrderSourcePersonCell.reuseIdentifier)
|
||||
tableView.register(OrderSourceLeadCell.self, forCellReuseIdentifier: OrderSourceLeadCell.reuseIdentifier)
|
||||
|
||||
loadingIndicator.hidesWhenStopped = true
|
||||
emptyLabel.font = .systemFont(ofSize: 14)
|
||||
emptyLabel.textColor = AppColor.text666
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.numberOfLines = 0
|
||||
|
||||
view.addSubview(headerStack)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(loadingIndicator)
|
||||
view.addSubview(emptyLabel)
|
||||
|
||||
headerStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(28)
|
||||
}
|
||||
leadCallButton.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(32)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerStack.snp.bottom).offset(14)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
loadingIndicator.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
make.leading.trailing.equalTo(tableView).inset(AppSpacing.lg)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
let currentSelection = viewModel.orderSourceSelection(
|
||||
orderNumber: orderNumber,
|
||||
referralOrder: referralOrder
|
||||
)
|
||||
titleLabel.isHidden = level == .lead
|
||||
subtitleLabel.isHidden = level == .lead
|
||||
leadHeaderRow.isHidden = level == .person
|
||||
|
||||
if level == .lead, let person = pendingPerson {
|
||||
leadTitleLabel.text = "\(person.name)创建的带客单"
|
||||
let phone = person.phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
leadPhoneButton.setTitle(phone, for: .normal)
|
||||
leadPhoneButton.isHidden = phone.isEmpty
|
||||
leadCallButton.isHidden = phone.isEmpty
|
||||
}
|
||||
|
||||
let isLoadingPeople = viewModel.isLoadingOrderSourcePeople && level == .person
|
||||
let isLoadingLeads = viewModel.loadingOrderSourceLeadsFor != nil && level == .lead
|
||||
if isLoadingPeople || isLoadingLeads {
|
||||
loadingIndicator.startAnimating()
|
||||
tableView.isHidden = true
|
||||
emptyLabel.isHidden = true
|
||||
} else {
|
||||
loadingIndicator.stopAnimating()
|
||||
tableView.isHidden = false
|
||||
let isEmpty: Bool
|
||||
switch level {
|
||||
case .person:
|
||||
isEmpty = viewModel.orderSourcePeople.isEmpty
|
||||
case .lead:
|
||||
isEmpty = leadsForPendingPerson.isEmpty
|
||||
}
|
||||
emptyLabel.isHidden = !isEmpty
|
||||
emptyLabel.text = level == .person ? "暂无合作获客员" : "暂无可绑定带客单"
|
||||
tableView.reloadData()
|
||||
}
|
||||
_ = currentSelection
|
||||
}
|
||||
|
||||
private var leadsForPendingPerson: [OrderSourceLead] {
|
||||
guard let person = pendingPerson else { return [] }
|
||||
return viewModel.orderSourceLeads[person.key] ?? []
|
||||
}
|
||||
|
||||
@objc private func backTapped() {
|
||||
level = .person
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
@objc private func leadPhoneTapped() {
|
||||
guard let phone = pendingPerson?.phone.trimmingCharacters(in: .whitespacesAndNewlines), !phone.isEmpty else { return }
|
||||
onCallPhone(phone)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
showingLeads ? leads.count : people.count
|
||||
switch level {
|
||||
case .person:
|
||||
return viewModel.orderSourcePeople.count
|
||||
case .lead:
|
||||
return leadsForPendingPerson.count
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
|
||||
if showingLeads {
|
||||
let lead = leads[indexPath.row]
|
||||
cell.textLabel?.text = lead.displayText
|
||||
cell.detailTextLabel?.text = lead.userMobile
|
||||
} else {
|
||||
let person = people[indexPath.row]
|
||||
cell.textLabel?.text = person.name
|
||||
cell.detailTextLabel?.text = person.phone
|
||||
let currentSelection = viewModel.orderSourceSelection(
|
||||
orderNumber: orderNumber,
|
||||
referralOrder: referralOrder
|
||||
)
|
||||
switch level {
|
||||
case .person:
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: OrderSourcePersonCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! OrderSourcePersonCell
|
||||
let person = viewModel.orderSourcePeople[indexPath.row]
|
||||
cell.configure(
|
||||
person: person,
|
||||
selected: currentSelection?.person.key == person.key
|
||||
)
|
||||
cell.onCallPhone = onCallPhone
|
||||
return cell
|
||||
case .lead:
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: OrderSourceLeadCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! OrderSourceLeadCell
|
||||
let lead = leadsForPendingPerson[indexPath.row]
|
||||
let selected = currentSelection?.person.key == pendingPerson?.key
|
||||
&& currentSelection?.lead.key == lead.key
|
||||
cell.configure(lead: lead, selected: selected)
|
||||
cell.onCallPhone = onCallPhone
|
||||
cell.onImageTap = onPreviewImages
|
||||
cell.onSelect = { [weak self] in
|
||||
guard let self, let person = self.pendingPerson else { return }
|
||||
let selection = OrderSourceSelection(person: person, lead: lead)
|
||||
self.onLeadBound(selection)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
if showingLeads, let person = selectedPerson {
|
||||
let lead = leads[indexPath.row]
|
||||
onLeadSelected?(OrderSourceSelection(person: person, lead: lead))
|
||||
dismiss(animated: true)
|
||||
} else {
|
||||
onPersonSelected?(people[indexPath.row])
|
||||
}
|
||||
guard level == .person else { return }
|
||||
let person = viewModel.orderSourcePeople[indexPath.row]
|
||||
pendingPerson = person
|
||||
level = .lead
|
||||
onPersonSelected(person)
|
||||
applyViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
||||
private var hasInitialized = false
|
||||
private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin }
|
||||
private var pendingOrderSourceNumber: String?
|
||||
private var pendingOrderSourcePerson: OrderSourcePerson?
|
||||
private weak var orderSourcePicker: OrderSourcePickerViewController?
|
||||
private var pendingGiftOrderNumber: String?
|
||||
private var isFetchingMultiVerifySpots = false
|
||||
private let locationProvider = OrderLocationProvider()
|
||||
@ -51,11 +51,17 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
||||
|
||||
override func bindActions() {
|
||||
orderListViewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
Task { @MainActor in
|
||||
self?.applyViewModel()
|
||||
self?.orderSourcePicker?.reloadFromViewModel()
|
||||
}
|
||||
}
|
||||
orderListViewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
orderListViewModel.onNavigateBindAcquirer = { [weak self] saleUserId in
|
||||
Task { @MainActor in self?.presentBindAcquirer(saleUserId: saleUserId) }
|
||||
}
|
||||
depositViewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
@ -259,6 +265,13 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
||||
present(nav, animated: true)
|
||||
}
|
||||
|
||||
private func presentBindAcquirer(saleUserId: Int) {
|
||||
dismiss(animated: true) { [weak self] in
|
||||
let controller = BindAcquirerViewController(saleUserId: saleUserId)
|
||||
self?.navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchMultiVerifySpotsIfNeeded() async {
|
||||
isFetchingMultiVerifySpots = true
|
||||
defer { isFetchingMultiVerifySpots = false }
|
||||
@ -322,7 +335,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
||||
hideWriteActions: orderListViewModel.isScenicAdminReadOnly,
|
||||
actions: makeOrderActions(for: order),
|
||||
showOrderSource: orderListViewModel.shouldShowOrderSource(for: order),
|
||||
sourceText: source.map { "\($0.person.name) · \($0.lead.displayText)" }
|
||||
sourceSelection: source
|
||||
)
|
||||
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: orderListViewModel.shouldLoadMore(at: indexPath.row))
|
||||
return cell
|
||||
@ -363,6 +376,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
||||
actions.onOrderSourceTap = { [weak self] in
|
||||
self?.presentOrderSourcePicker(for: order)
|
||||
}
|
||||
actions.onOrderSourceImageTap = { [weak self] images, index in
|
||||
self?.presentImagePreview(images: images, startIndex: index)
|
||||
}
|
||||
if let travel = order.photoTravel, travel.canGiftRetouch {
|
||||
actions.onSendGift = { [weak self] in
|
||||
guard let self else { return }
|
||||
@ -451,23 +467,23 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
||||
pendingOrderSourceNumber = order.orderNumber
|
||||
Task {
|
||||
await orderListViewModel.onOrderSourceSheetOpen(api: orderAPI)
|
||||
let controller = OrderSourcePickerViewController(
|
||||
people: orderListViewModel.orderSourcePeople,
|
||||
leads: [],
|
||||
selectedPerson: nil
|
||||
)
|
||||
controller.onPersonSelected = { [weak self] person in
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
let controller = OrderSourcePickerViewController(
|
||||
orderNumber: order.orderNumber,
|
||||
viewModel: self.orderListViewModel,
|
||||
referralOrder: order.referralOrder,
|
||||
onPersonSelected: { [weak self] person in
|
||||
guard let self else { return }
|
||||
self.pendingOrderSourcePerson = person
|
||||
Task {
|
||||
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
|
||||
let leadsController = OrderSourcePickerViewController(
|
||||
people: self.orderListViewModel.orderSourcePeople,
|
||||
leads: self.orderListViewModel.orderSourceLeads[person.key] ?? [],
|
||||
selectedPerson: person
|
||||
)
|
||||
leadsController.onLeadSelected = { [weak self] selection in
|
||||
}
|
||||
},
|
||||
onLeadBound: { [weak self] selection in
|
||||
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
|
||||
self.dismiss(animated: true) {
|
||||
self.orderSourcePicker = nil
|
||||
}
|
||||
Task {
|
||||
await self.orderListViewModel.bindOrderSource(
|
||||
orderNumber: orderNumber,
|
||||
@ -475,11 +491,44 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
||||
api: self.orderAPI
|
||||
)
|
||||
}
|
||||
},
|
||||
onCallPhone: { [weak self] phone in
|
||||
self?.callPhone(phone)
|
||||
},
|
||||
onPreviewImages: { [weak self] urls, index in
|
||||
self?.presentImagePreview(images: urls, startIndex: index)
|
||||
}
|
||||
self.present(leadsController, animated: true)
|
||||
)
|
||||
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()]
|
||||
}
|
||||
}
|
||||
present(controller, animated: true)
|
||||
self.present(nav, 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 badgeContainer = UIView()
|
||||
private var badgeView: ProfileStatusBadgeView?
|
||||
private let rightStack = UIStackView()
|
||||
private let stackNameLabel = UILabel()
|
||||
private let stackTypeTag = UILabel()
|
||||
private var rowHeightConstraint: Constraint?
|
||||
|
||||
var showsDivider = true {
|
||||
didSet { divider.isHidden = !showsDivider }
|
||||
@ -44,6 +48,8 @@ final class ProfileInfoRowView: UIControl {
|
||||
accessory: AccessoryStyle = .none,
|
||||
badge: ProfileStatusBadgeView.Style? = nil
|
||||
) {
|
||||
setStackedModeEnabled(false)
|
||||
|
||||
valueLabel.text = value
|
||||
valueLabel.textColor = valueColor
|
||||
valueLabel.isHidden = badge != nil
|
||||
@ -73,6 +79,84 @@ final class ProfileInfoRowView: UIControl {
|
||||
accessoryLabel.isHidden = false
|
||||
chevronView.isHidden = false
|
||||
}
|
||||
|
||||
updateContentConstraints(for: accessory)
|
||||
}
|
||||
|
||||
/// 右侧竖排展示店铺名称与账号类型标识,可选右侧箭头。
|
||||
func configureAccountInfo(
|
||||
storeName: String,
|
||||
accountTypeLabel: String,
|
||||
isStoreAccount: Bool,
|
||||
showsChevron: Bool = true
|
||||
) {
|
||||
setStackedModeEnabled(true, showsChevron: showsChevron)
|
||||
|
||||
stackNameLabel.text = storeName
|
||||
stackTypeTag.text = accountTypeLabel
|
||||
if isStoreAccount {
|
||||
stackTypeTag.textColor = UIColor(hex: 0x0F9F6E)
|
||||
stackTypeTag.backgroundColor = UIColor(hex: 0xE8F8F1)
|
||||
} else {
|
||||
stackTypeTag.textColor = UIColor(hex: 0x7C3AED)
|
||||
stackTypeTag.backgroundColor = UIColor(hex: 0xF3ECFF)
|
||||
}
|
||||
|
||||
updateStackedConstraints(showsChevron: showsChevron)
|
||||
}
|
||||
|
||||
private func setStackedModeEnabled(_ enabled: Bool, showsChevron: Bool = false) {
|
||||
rightStack.isHidden = !enabled
|
||||
accessoryLabel.isHidden = true
|
||||
chevronView.isHidden = !enabled || !showsChevron
|
||||
|
||||
if enabled {
|
||||
valueLabel.isHidden = true
|
||||
badgeContainer.isHidden = true
|
||||
rowHeightConstraint?.update(offset: 64)
|
||||
} else {
|
||||
rowHeightConstraint?.update(offset: 52)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStackedConstraints(showsChevron: Bool) {
|
||||
rightStack.snp.remakeConstraints { make in
|
||||
make.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
|
||||
if showsChevron {
|
||||
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
||||
} else {
|
||||
make.trailing.equalToSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateContentConstraints(for accessory: AccessoryStyle) {
|
||||
valueLabel.snp.remakeConstraints { make in
|
||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalToSuperview()
|
||||
switch accessory {
|
||||
case .action:
|
||||
make.trailing.equalTo(accessoryLabel.snp.leading).offset(-8)
|
||||
case .chevron:
|
||||
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
||||
case .none:
|
||||
make.trailing.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
badgeContainer.snp.remakeConstraints { make in
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalToSuperview()
|
||||
switch accessory {
|
||||
case .action:
|
||||
make.trailing.lessThanOrEqualTo(accessoryLabel.snp.leading).offset(-8)
|
||||
case .chevron:
|
||||
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
|
||||
case .none:
|
||||
make.trailing.lessThanOrEqualToSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
@ -83,13 +167,36 @@ final class ProfileInfoRowView: UIControl {
|
||||
valueLabel.font = .systemFont(ofSize: 14)
|
||||
valueLabel.textAlignment = .right
|
||||
valueLabel.numberOfLines = 1
|
||||
valueLabel.lineBreakMode = .byTruncatingTail
|
||||
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
accessoryLabel.font = .systemFont(ofSize: 14)
|
||||
accessoryLabel.textColor = AppColor.primary
|
||||
accessoryLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
accessoryLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
|
||||
chevronView.image = UIImage(systemName: "chevron.right")
|
||||
chevronView.tintColor = AppColor.text666
|
||||
chevronView.contentMode = .scaleAspectFit
|
||||
chevronView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
stackNameLabel.font = .systemFont(ofSize: 14)
|
||||
stackNameLabel.textColor = AppColor.text333
|
||||
stackNameLabel.textAlignment = .right
|
||||
stackNameLabel.numberOfLines = 1
|
||||
stackNameLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
stackTypeTag.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
stackTypeTag.textAlignment = .center
|
||||
stackTypeTag.layer.cornerRadius = 4
|
||||
stackTypeTag.clipsToBounds = true
|
||||
|
||||
rightStack.axis = .vertical
|
||||
rightStack.alignment = .trailing
|
||||
rightStack.spacing = 4
|
||||
rightStack.addArrangedSubview(stackNameLabel)
|
||||
rightStack.addArrangedSubview(stackTypeTag)
|
||||
rightStack.isHidden = true
|
||||
|
||||
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
|
||||
|
||||
@ -98,10 +205,11 @@ final class ProfileInfoRowView: UIControl {
|
||||
addSubview(badgeContainer)
|
||||
addSubview(accessoryLabel)
|
||||
addSubview(chevronView)
|
||||
addSubview(rightStack)
|
||||
addSubview(divider)
|
||||
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(52)
|
||||
rowHeightConstraint = make.height.equalTo(52).constraint
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview()
|
||||
@ -117,16 +225,16 @@ final class ProfileInfoRowView: UIControl {
|
||||
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
rightStack.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview()
|
||||
make.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
|
||||
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
badgeContainer.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
|
||||
stackTypeTag.snp.makeConstraints { make in
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(56)
|
||||
}
|
||||
updateContentConstraints(for: .none)
|
||||
divider.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
|
||||
@ -31,6 +31,7 @@ final class ProfileViewController: BaseViewController {
|
||||
private let statusRow = ProfileInfoRowView(title: "账号状态")
|
||||
private let scenicRow = ProfileInfoRowView(title: "当前景区")
|
||||
private let logoutButton = UIButton(type: .system)
|
||||
private var hasLoadedProfileOnce = false
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "我的"
|
||||
@ -101,7 +102,13 @@ final class ProfileViewController: BaseViewController {
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
Task { await reloadProfile(showGlobalLoading: true) }
|
||||
let showLoading = !hasLoadedProfileOnce
|
||||
Task {
|
||||
await reloadProfile(showGlobalLoading: showLoading)
|
||||
if showLoading {
|
||||
hasLoadedProfileOnce = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let headerContainer = UIView()
|
||||
@ -194,9 +201,10 @@ final class ProfileViewController: BaseViewController {
|
||||
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
|
||||
|
||||
nameRow.configure(value: viewModel.displayRealName)
|
||||
accountRow.configure(
|
||||
value: viewModel.accountDisplayName,
|
||||
accessory: .action("切换账号")
|
||||
accountRow.configureAccountInfo(
|
||||
storeName: viewModel.accountDisplayName,
|
||||
accountTypeLabel: viewModel.accountTypeLabel,
|
||||
isStoreAccount: viewModel.isStoreAccount
|
||||
)
|
||||
phoneRow.configure(value: viewModel.displayPhone)
|
||||
|
||||
|
||||
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(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?.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() {
|
||||
AppStore.shared.userName = "全局昵称"
|
||||
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