添加景区选择与合作订单流程并对齐 Android

This commit is contained in:
2026-07-07 10:48:43 +08:00
parent ef1d3b4af5
commit c4057537d2
50 changed files with 5603 additions and 191 deletions

View File

@ -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"
}
}

View File

@ -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?()
}
}

View File

@ -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?()
}
}

View File

@ -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?()
}
}