添加景区选择与合作订单流程并对齐 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?()
}
}

View File

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

View 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
}
}

View 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
}
}
}

View File

@ -47,7 +47,7 @@ enum HomeRouteHandler {
showToast("暂无合作订单权限", from: viewController) showToast("暂无合作订单权限", from: viewController)
return return
} }
showDeveloping(from: viewController) viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true)
case "task_management", "task_management_editor": case "task_management", "task_management_editor":
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true) viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
default: default:

View File

@ -212,9 +212,17 @@ final class HomeViewModel {
} }
} }
/// /// Android `initCurrentScenicName`
func refreshLocalDisplayState() { func refreshLocalDisplayState() {
currentScenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) let scenicId = appStore.currentScenicId
let scenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
if scenicId > 0, !scenicName.isEmpty {
currentScenicName = scenicName
} else {
currentScenicName = ""
appStore.currentScenicId = 0
appStore.currentScenicName = ""
}
currentAppRole = appStore.currentAppRole currentAppRole = appStore.currentAppRole
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
} }

View File

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

View File

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

View File

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

View File

@ -206,6 +206,93 @@ final class OrderAPI {
) )
} }
func referralLeads(page: String = "1", pageSize: String = "10") async throws -> OrderListResponse<ReferralLeadEntity> {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/referral-leads",
queryItems: [
URLQueryItem(name: "page", value: page),
URLQueryItem(name: "page_size", value: pageSize),
]
)
)
}
func acquisitionOrders(keyword: String? = nil, page: String = "1", pageSize: String = "10") async throws -> OrderListResponse<AcquisitionOrderEntity> {
var items = [
URLQueryItem(name: "page", value: page),
URLQueryItem(name: "page_size", value: pageSize),
]
if let keyword, !keyword.isEmpty {
items.append(URLQueryItem(name: "keyword", value: keyword))
}
return try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/acquisition-orders",
queryItems: items
)
)
}
func saleUserInviteInfo(saleUserId: String) async throws -> SaleUserInviteInfoResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/invite-info",
queryItems: [URLQueryItem(name: "sale_user_id", value: saleUserId)]
)
)
}
func saleUserAcceptBind(saleUserId: Int, commissionRate: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/accept-bind",
queryItems: [
URLQueryItem(name: "sale_user_id", value: String(saleUserId)),
URLQueryItem(name: "commission_rate", value: String(commissionRate)),
]
)
)
}
func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/update-remark",
body: SaleUserUpdateRemarkRequest(saleUserId: saleUserId, photogRemarkName: photogRemarkName)
)
)
}
func saleUserCommissionRateSendSmsCode(phone: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/commission-rate/send-sms-code",
body: SaleUserCommissionRateSmsRequest(phone: phone)
)
)
}
func saleUserUpdateCommissionRate(saleUserId: Int, commissionRate: Int, smsCode: String) async throws -> CooperativeSalerEntity {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/commission-rate",
body: SaleUserCommissionRateUpdateRequest(
saleUserId: saleUserId,
commissionRate: commissionRate,
smsCode: smsCode
)
)
)
}
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
try await client.send( try await client.send(
APIRequest( APIRequest(

View File

@ -789,10 +789,19 @@ struct CooperativeSalerEntity: Decodable, Equatable {
let photogRemarkName: String let photogRemarkName: String
let phone: String let phone: String
let salerPhone: String let salerPhone: String
let commissionRate: Int
let commissionRateLabel: String
let bindTime: String
let boundAt: String
var displayId: Int { saleUserId } var displayId: Int { saleUserId }
var displayName: String { OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) } var displayName: String { OrderDisplayName.saleUser(remark: photogRemarkName, primary: name, fallback: salerName) }
var displayPhone: String { salerPhone.isEmpty ? phone : salerPhone } var displayPhone: String { salerPhone.isEmpty ? phone : salerPhone }
var displayBindTime: String { bindTime.isEmpty ? boundAt : bindTime }
var displayCommissionRate: String {
if !commissionRateLabel.isEmpty { return commissionRateLabel }
return "\(commissionRate)%"
}
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case saleUserId = "sale_user_id" case saleUserId = "sale_user_id"
@ -802,6 +811,67 @@ struct CooperativeSalerEntity: Decodable, Equatable {
case photogRemarkName = "photog_remark_name" case photogRemarkName = "photog_remark_name"
case phone case phone
case salerPhone = "saler_phone" case salerPhone = "saler_phone"
case commissionRate = "commission_rate"
case commissionRateLabel = "commission_rate_label"
case bindTime = "bind_time"
case boundAt = "bound_at"
}
init(
saleUserId: Int = 0,
bindingId: Int = 0,
name: String = "",
salerName: String = "",
photogRemarkName: String = "",
phone: String = "",
salerPhone: String = "",
commissionRate: Int = 0,
commissionRateLabel: String = "",
bindTime: String = "",
boundAt: String = ""
) {
self.saleUserId = saleUserId
self.bindingId = bindingId
self.name = name
self.salerName = salerName
self.photogRemarkName = photogRemarkName
self.phone = phone
self.salerPhone = salerPhone
self.commissionRate = commissionRate
self.commissionRateLabel = commissionRateLabel
self.bindTime = bindTime
self.boundAt = boundAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
saleUserId = try container.decodeIfPresent(Int.self, forKey: .saleUserId) ?? 0
bindingId = try container.decodeIfPresent(Int.self, forKey: .bindingId) ?? 0
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
salerName = try container.decodeIfPresent(String.self, forKey: .salerName) ?? ""
photogRemarkName = try container.decodeIfPresent(String.self, forKey: .photogRemarkName) ?? ""
phone = try container.decodeIfPresent(String.self, forKey: .phone) ?? ""
salerPhone = try container.decodeIfPresent(String.self, forKey: .salerPhone) ?? ""
commissionRate = try container.decodeIfPresent(Int.self, forKey: .commissionRate) ?? 0
commissionRateLabel = try container.decodeIfPresent(String.self, forKey: .commissionRateLabel) ?? ""
bindTime = try container.decodeIfPresent(String.self, forKey: .bindTime) ?? ""
boundAt = try container.decodeIfPresent(String.self, forKey: .boundAt) ?? ""
}
func withRemarkName(_ remark: String) -> CooperativeSalerEntity {
CooperativeSalerEntity(
saleUserId: saleUserId,
bindingId: bindingId,
name: name,
salerName: salerName,
photogRemarkName: remark,
phone: phone,
salerPhone: salerPhone,
commissionRate: commissionRate,
commissionRateLabel: commissionRateLabel,
bindTime: bindTime,
boundAt: boundAt
)
} }
} }
@ -819,6 +889,7 @@ struct ReferralOrderEntity: Decodable, Equatable {
let createdAt: String let createdAt: String
let status: Int let status: Int
let statusLabel: String let statusLabel: String
let images: [String]
var displayReferralOrderNo: String { referralOrderNo.isEmpty ? orderNo : referralOrderNo } var displayReferralOrderNo: String { referralOrderNo.isEmpty ? orderNo : referralOrderNo }
@ -831,6 +902,20 @@ struct ReferralOrderEntity: Decodable, Equatable {
case createdAt = "created_at" case createdAt = "created_at"
case status case status
case statusLabel = "status_label" case statusLabel = "status_label"
case images
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
referralOrderNo = try container.decodeIfPresent(String.self, forKey: .referralOrderNo) ?? ""
orderNo = try container.decodeIfPresent(String.self, forKey: .orderNo) ?? ""
userMobile = try container.decodeIfPresent(String.self, forKey: .userMobile) ?? ""
remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? ""
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
statusLabel = try container.decodeIfPresent(String.self, forKey: .statusLabel) ?? ""
images = (try? container.decode([String].self, forKey: .images)) ?? []
} }
} }

View File

@ -15,6 +15,7 @@ final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
var onShowWriteOff: ((WriteOffOrderResponse) -> Void)? var onShowWriteOff: ((WriteOffOrderResponse) -> Void)?
var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)? var onMultiVerifyPending: ((MultiVerifyParseData) -> Void)?
var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)? var onShowMultiVerifySheet: (([MultiTravelScenicSpotEntity], MultiTravelScenicSpotEntity?) -> Void)?
var onNavigateBindAcquirer: ((Int) -> Void)?
private let api: OrderAPI private let api: OrderAPI
private let locationManager = CLLocationManager() private let locationManager = CLLocationManager()
@ -64,7 +65,7 @@ final class MainTabScanHandler: NSObject, CLLocationManagerDelegate {
await fetchSpotsForMultiVerify(pending: pending) await fetchSpotsForMultiVerify(pending: pending)
case SaleUserQrParser.methodSaleUserBind: case SaleUserQrParser.methodSaleUserBind:
if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 { if let id = SaleUserQrParser.parseSaleUserId(scanResult), id > 0 {
onShowMessage?("获客员绑定功能待接入") onNavigateBindAcquirer?(id)
} else { } else {
onShowMessage?("请扫描正确的获客员二维码") onShowMessage?("请扫描正确的获客员二维码")
} }

View File

@ -20,9 +20,59 @@ struct OrderSourcePerson: Equatable {
struct OrderSourceLead: Equatable { struct OrderSourceLead: Equatable {
let key: String let key: String
let displayText: String let userPhone: String
let userMobile: String let remark: String
let createdAt: String let images: [String]
}
enum OrderSourceMapping {
/// API Android `toOrderSourceLead()`
static func lead(from entity: ReferralOrderEntity) -> OrderSourceLead {
let orderNo = entity.displayReferralOrderNo
let remark: String
if !entity.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
remark = entity.remark
} else if !entity.statusLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
remark = entity.statusLabel
} else {
remark = "带客单 \(orderNo)"
}
return OrderSourceLead(
key: orderNo,
userPhone: entity.userMobile,
remark: remark,
images: entity.images
)
}
/// Android `toOrderSourceSelection()`
static func selection(from referral: BoundReferralOrderEntity) -> OrderSourceSelection? {
let referralNo = referral.orderNo.trimmingCharacters(in: .whitespacesAndNewlines)
guard !referralNo.isEmpty else { return nil }
let personName = referral.displaySalerName.isEmpty ? "" : referral.displaySalerName
let person = OrderSourcePerson(
key: referral.saleUserId > 0 ? String(referral.saleUserId) : "",
name: personName,
phone: referral.salerPhone
)
let remark: String
if !referral.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
remark = referral.remark
} else if !referral.displayText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
remark = referral.displayText
} else {
remark = "带客单号 \(referralNo)"
}
let lead = OrderSourceLead(
key: referralNo,
userPhone: referral.userMobile,
remark: remark,
images: []
)
return OrderSourceSelection(person: person, lead: lead)
}
} }
struct OrderSourceSelection: Equatable { struct OrderSourceSelection: Equatable {
@ -54,13 +104,14 @@ final class OrderListViewModel {
private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:] private(set) var orderSourceLeads: [String: [OrderSourceLead]] = [:]
private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:] private(set) var orderSourceSelections: [String: OrderSourceSelection] = [:]
private(set) var loadingOrderSourceLeadsFor: String? private(set) var loadingOrderSourceLeadsFor: String?
private(set) var isLoadingOrderSourcePeople = false
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)? var onShowMessage: ((String) -> Void)?
var onNavigateBindAcquirer: ((Int) -> Void)?
private var currentPage = 1 private var currentPage = 1
private var totalCount = 0 private var totalCount = 0
private var loadingOrderSourcePeople = false
private let apiDateFormatter: DateFormatter = { private let apiDateFormatter: DateFormatter = {
let formatter = DateFormatter() let formatter = DateFormatter()
@ -188,7 +239,7 @@ final class OrderListViewModel {
return return
case SaleUserQrParser.methodSaleUserBind: case SaleUserQrParser.methodSaleUserBind:
if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 { if let saleUserId = SaleUserQrParser.parseSaleUserId(scanResult), saleUserId > 0 {
onShowMessage?("获客员绑定功能待接入") onNavigateBindAcquirer?(saleUserId)
} else { } else {
onShowMessage?("请扫描正确的获客员二维码") onShowMessage?("请扫描正确的获客员二维码")
} }
@ -287,27 +338,27 @@ final class OrderListViewModel {
} }
func orderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? { func orderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
if let selection = orderSourceSelections[order.orderNumber] { return selection } orderSourceSelection(orderNumber: order.orderNumber, referralOrder: order.referralOrder)
guard let referral = order.referralOrder else { return nil } }
let person = OrderSourcePerson(
key: String(referral.saleUserId), /// Sheet 使
name: referral.displaySalerName, func orderSourceSelection(orderNumber: String, referralOrder: BoundReferralOrderEntity? = nil) -> OrderSourceSelection? {
phone: referral.salerPhone if let selection = orderSourceSelections[orderNumber] { return selection }
) if let referral = referralOrder ?? orderList.first(where: { $0.orderNumber == orderNumber })?.referralOrder {
let lead = OrderSourceLead( return OrderSourceMapping.selection(from: referral)
key: referral.orderNo, }
displayText: referral.displayText.isEmpty ? referral.orderNo : referral.displayText, return nil
userMobile: referral.userMobile,
createdAt: referral.createdAt
)
return OrderSourceSelection(person: person, lead: lead)
} }
func onOrderSourceSheetOpen(api: OrderAPI) async { func onOrderSourceSheetOpen(api: OrderAPI) async {
orderSourceLeads = [:] orderSourceLeads = [:]
guard !loadingOrderSourcePeople else { return } guard !isLoadingOrderSourcePeople else { return }
loadingOrderSourcePeople = true isLoadingOrderSourcePeople = true
defer { loadingOrderSourcePeople = false } notifyStateChange()
defer {
isLoadingOrderSourcePeople = false
notifyStateChange()
}
let storeId = AppStore.shared.currentStoreId let storeId = AppStore.shared.currentStoreId
let storeIdParam = storeId > 0 ? String(storeId) : nil let storeIdParam = storeId > 0 ? String(storeId) : nil
do { do {
@ -331,15 +382,7 @@ final class OrderListViewModel {
} }
do { do {
let response = try await api.referralOrders(saleUserId: person.key) let response = try await api.referralOrders(saleUserId: person.key)
let leads = response.list.map { orderSourceLeads[person.key] = response.list.map(OrderSourceMapping.lead(from:))
OrderSourceLead(
key: $0.displayReferralOrderNo,
displayText: $0.displayReferralOrderNo,
userMobile: $0.userMobile,
createdAt: $0.createdAt
)
}
orderSourceLeads[person.key] = leads
notifyStateChange() notifyStateChange()
} catch { } catch {
onShowMessage?(error.localizedDescription) onShowMessage?(error.localizedDescription)

View File

@ -42,6 +42,23 @@ final class ProfileViewModel {
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name
} }
///
var accountTypeLabel: String {
let type = AppStore.shared.accountType.trimmingCharacters(in: .whitespacesAndNewlines)
if type == V9StoreUser.accountTypeValue {
return "门店账号"
}
if type == V9ScenicUser.accountTypeValue {
return "景区账号"
}
return "--"
}
///
var isStoreAccount: Bool {
AppStore.shared.accountType == V9StoreUser.accountTypeValue
}
var currentScenicName: String { var currentScenicName: String {
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name

View File

@ -21,6 +21,8 @@ enum OrderTokens {
static let giftBorder = UIColor(hex: 0xE5E7EB) static let giftBorder = UIColor(hex: 0xE5E7EB)
static let sourceBorder = UIColor(hex: 0xE6EAF0) static let sourceBorder = UIColor(hex: 0xE6EAF0)
static let sourceBackground = UIColor(hex: 0xF9FAFC) static let sourceBackground = UIColor(hex: 0xF9FAFC)
static let sourceDivider = UIColor(hex: 0xE9EDF3)
static let sourceLeadPlaceholder = UIColor(hex: 0xF3F5F8)
static let depositSuccess = UIColor(hex: 0x27AE60) static let depositSuccess = UIColor(hex: 0x27AE60)
static let depositSuccessBackground = UIColor(hex: 0xE8F8F0) static let depositSuccessBackground = UIColor(hex: 0xE8F8F0)
static let depositRefunded = UIColor(hex: 0x6B7280) static let depositRefunded = UIColor(hex: 0x6B7280)

View 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)
}
}

View File

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

View File

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

View 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
}
}

View 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))"
}
}

View File

@ -89,6 +89,11 @@ final class HomeViewController: BaseViewController {
) )
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
if !hasInitialized { if !hasInitialized {
@ -361,18 +366,17 @@ final class HomeViewController: BaseViewController {
private func presentScenicDialog() { private func presentScenicDialog() {
let alert = UIAlertController( let alert = UIAlertController(
title: "请选择景区", title: "提示",
message: "使用首页功能前需要先选择当前景区", message: "您还没有选定入驻景区,请先入驻",
preferredStyle: .alert preferredStyle: .alert
) )
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in alert.isModalInPresentation = true
self?.viewModel.hideScenicDialog() alert.addAction(UIAlertAction(title: "关闭app", style: .default) { _ in
self?.activeDialog = nil UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
}) })
alert.addAction(UIAlertAction(title: "选择", style: .default) { [weak self] _ in alert.addAction(UIAlertAction(title: "入驻", style: .default) { [weak self] _ in
self?.viewModel.hideScenicDialog()
self?.activeDialog = nil self?.activeDialog = nil
self?.presentScenicSelection() self?.pushScenicSelection()
}) })
present(alert, animated: true) present(alert, animated: true)
} }
@ -440,24 +444,24 @@ final class HomeViewController: BaseViewController {
} }
private func presentScenicSelection() { private func presentScenicSelection() {
let scenicList = AppStore.shared.roleScenicList() pushScenicSelection()
guard !scenicList.isEmpty else { }
showToast("暂无可选景区")
return private func pushScenicSelection() {
} navigationController?.setNavigationBarHidden(false, animated: true)
let controller = ScenicSelectionViewController(scenicList: scenicList) let controller = ScenicSelectionViewController()
controller.onSelected = { [weak self] in controller.onSelectionComplete = { [weak self] in
guard let self else { return } guard let self else { return }
Task { Task {
await self.viewModel.reloadIfNeeded(api: self.homeAPI) self.viewModel.refreshLocalDisplayState()
self.viewModel.rebuildCommonMenus()
await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI) await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI)
self.applyViewModel() self.applyViewModel()
await self.evaluateDialogsWithDelay() await self.viewModel.evaluateDialogs(api: self.homeAPI)
await MainActor.run { self.presentDialogsIfNeeded() }
} }
} }
let nav = UINavigationController(rootViewController: controller) navigationController?.pushViewController(controller, animated: true)
nav.modalPresentationStyle = .pageSheet
present(nav, animated: true)
} }
private func startCountdownTimer() { private func startCountdownTimer() {

View 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)
}
}

View 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)
}
}

View File

@ -6,16 +6,23 @@
import SnapKit import SnapKit
import UIKit import UIKit
/// 使 role-permission /// Android `ScenicSelectionScreen`
final class ScenicSelectionViewController: BaseViewController { final class ScenicSelectionViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate {
var onSelected: (() -> Void)? var onSelectionComplete: (() -> Void)?
private let scenicList: [ScenicInfo] private let viewModel: ScenicSelectionViewModel
private let tableView = UITableView(frame: .zero, style: .insetGrouped) private let homeAPI: HomeAPI
init(scenicList: [ScenicInfo]) { private let bannerView = ScenicPermissionBannerView()
self.scenicList = scenicList private let searchBar = ScenicSelectionSearchBar()
private let locationBar = ScenicCurrentLocationBar()
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) {
self.viewModel = viewModel
self.homeAPI = homeAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -25,61 +32,122 @@ final class ScenicSelectionViewController: BaseViewController {
} }
override func setupNavigationBar() { override func setupNavigationBar() {
title = "选择景区" title = "景区选择"
navigationItem.leftBarButtonItem = UIBarButtonItem( navigationController?.setNavigationBarHidden(false, animated: false)
barButtonSystemItem: .close,
target: self,
action: #selector(closeTapped)
)
} }
override func setupUI() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF) view.backgroundColor = .white
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.dataSource = self tableView.dataSource = self
tableView.delegate = self tableView.delegate = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") tableView.register(ScenicSpotCell.self, forCellReuseIdentifier: ScenicSpotCell.reuseIdentifier)
view.addSubview(tableView) tableView.keyboardDismissMode = .onDrag
}
override func setupConstraints() { emptyLabel.text = "暂无景区"
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
let headerStack = UIStackView(arrangedSubviews: [bannerView, searchBar, locationBar])
headerStack.axis = .vertical
headerStack.spacing = 16
headerStack.isLayoutMarginsRelativeArrangement = true
headerStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)
view.addSubview(headerStack)
view.addSubview(tableView)
view.addSubview(emptyLabel)
headerStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
}
tableView.snp.makeConstraints { make in tableView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide) make.top.equalTo(headerStack.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
emptyLabel.snp.makeConstraints { make in
make.center.equalTo(tableView)
} }
} }
@objc private func closeTapped() { override func bindActions() {
dismiss(animated: true) viewModel.onStateChange = { [weak self] in
} Task { @MainActor in self?.applyViewModel() }
} }
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSelectionComplete = { [weak self] in
Task { @MainActor in
self?.onSelectionComplete?()
self?.navigationController?.popViewController(animated: true)
}
}
viewModel.onNavigateApply = { [weak self] in
Task { @MainActor in self?.pushPermissionApply() }
}
viewModel.onNavigateApplyStatus = { [weak self] applyCode in
Task { @MainActor in self?.pushPermissionApplyStatus(applyCode: applyCode) }
}
bannerView.onApplyNow = { [weak self] in
guard let self else { return }
Task { await self.viewModel.onApplyNow(api: self.homeAPI) }
}
searchBar.onSearchTextChange = { [weak self] query in
self?.viewModel.onSearchQueryChange(query)
}
locationBar.onRelocate = { [weak self] in
guard let self else { return }
Task { await self.viewModel.startLocation() }
}
viewModel.loadFromLocal()
Task { await viewModel.startLocation() }
}
private func applyViewModel() {
locationBar.apply(locationText: viewModel.currentLocationText)
emptyLabel.isHidden = !viewModel.spots.isEmpty
tableView.reloadData()
if viewModel.isLoading {
showLoading()
} else {
hideLoading()
}
}
private func pushPermissionApply(pendingData: RoleApplyPendingResponse? = nil) {
let controller = PermissionApplyViewController(pendingData: pendingData)
navigationController?.pushViewController(controller, animated: true)
}
private func pushPermissionApplyStatus(applyCode: String) {
let controller = PermissionApplyStatusViewController(applyCode: applyCode)
navigationController?.pushViewController(controller, animated: true)
}
// MARK: - UITableView
extension ScenicSelectionViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
scenicList.count viewModel.spots.count
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) let cell = tableView.dequeueReusableCell(
var config = cell.defaultContentConfiguration() withIdentifier: ScenicSpotCell.reuseIdentifier,
config.text = scenicList[indexPath.row].name for: indexPath
cell.contentConfiguration = config ) as! ScenicSpotCell
cell.accessoryType = scenicList[indexPath.row].id == AppStore.shared.currentScenicId ? .checkmark : .none let spot = viewModel.spots[indexPath.row]
cell.apply(spot)
cell.onTap = { [weak self] in
self?.viewModel.selectSpot(spot)
}
return cell return cell
} }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let scenic = scenicList[indexPath.row]
AppStore.shared.currentScenicId = scenic.id
AppStore.shared.currentScenicName = scenic.name
NotificationCenter.default.post(
name: NotificationName.scenicDidChange,
object: nil,
userInfo: [
NotificationUserInfoKey.scenicId: scenic.id,
NotificationUserInfoKey.scenicName: scenic.name,
]
)
onSelected?()
dismiss(animated: true)
}
} }

View 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)
}
}

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

View File

@ -136,6 +136,24 @@ final class MainTabBarController: UITabBarController {
scanHandler.onShowMultiVerifySheet = { [weak self] spots, selected in scanHandler.onShowMultiVerifySheet = { [weak self] spots, selected in
self?.presentMultiVerifySheet(spots: spots, selected: selected) self?.presentMultiVerifySheet(spots: spots, selected: selected)
} }
scanHandler.onNavigateBindAcquirer = { [weak self] saleUserId in
self?.presentBindAcquirerAfterScan(saleUserId: saleUserId)
}
}
private func presentBindAcquirerAfterScan(saleUserId: Int) {
dismiss(animated: true) { [weak self] in
guard let self else { return }
let controller = BindAcquirerViewController(saleUserId: saleUserId)
self.topNavigationController()?.pushViewController(controller, animated: true)
}
}
private func topNavigationController() -> UINavigationController? {
if let selected = selectedViewController as? UINavigationController {
return selected
}
return selectedViewController?.navigationController
} }
private func showScanToast(_ message: String) { private func showScanToast(_ message: String) {

View File

@ -20,6 +20,7 @@ struct OrderCardActions {
var onRefinedChanged: ((Int) -> Void)? var onRefinedChanged: ((Int) -> Void)?
var onHistoricalPhoto: (() -> Void)? var onHistoricalPhoto: (() -> Void)?
var onOrderSourceTap: (() -> Void)? var onOrderSourceTap: (() -> Void)?
var onOrderSourceImageTap: (([String], Int) -> Void)?
} }
/// Android `OrderView` /// Android `OrderView`
@ -34,8 +35,7 @@ final class OrderCardCell: UITableViewCell {
private let statusBadge = OrderStatusBadgeView() private let statusBadge = OrderStatusBadgeView()
private let createdAtLabel = UILabel() private let createdAtLabel = UILabel()
private let typeChip = OrderTypeChipView() private let typeChip = OrderTypeChipView()
private let orderSourceContainer = UIView() private let orderSourceEntryView = OrderSourceEntryView()
private let orderSourceButton = UIButton(type: .system)
private let actionRow = UIStackView() private let actionRow = UIStackView()
private let refundButton = OrderActionButton(title: "退款", style: .destructiveLight) private let refundButton = OrderActionButton(title: "退款", style: .destructiveLight)
private let verifyButton = OrderActionButton(title: "核销订单", style: .primaryFilled) private let verifyButton = OrderActionButton(title: "核销订单", style: .primaryFilled)
@ -79,7 +79,7 @@ final class OrderCardCell: UITableViewCell {
hideWriteActions: Bool, hideWriteActions: Bool,
actions: OrderCardActions, actions: OrderCardActions,
showOrderSource: Bool, showOrderSource: Bool,
sourceText: String? sourceSelection: OrderSourceSelection?
) { ) {
self.actions = actions self.actions = actions
orderNumberLabel.text = "订单号:\(order.orderNumber)" orderNumberLabel.text = "订单号:\(order.orderNumber)"
@ -98,8 +98,10 @@ final class OrderCardCell: UITableViewCell {
uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19 uploadTaskButton.isHidden = hideWriteActions || order.orderType != 19
orderSourceContainer.isHidden = !showOrderSource orderSourceEntryView.isHidden = !showOrderSource
orderSourceButton.setTitle(sourceText ?? "选择订单来源", for: .normal) if showOrderSource {
orderSourceEntryView.apply(selection: sourceSelection)
}
let showEdit = (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19 let showEdit = (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19
editOptionView.isHidden = !showEdit editOptionView.isHidden = !showEdit
@ -149,17 +151,9 @@ final class OrderCardCell: UITableViewCell {
headerRow.addArrangedSubview(orderNumberLabel) headerRow.addArrangedSubview(orderNumberLabel)
headerRow.addArrangedSubview(statusBadge) headerRow.addArrangedSubview(statusBadge)
orderSourceContainer.backgroundColor = OrderTokens.sourceBackground orderSourceEntryView.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside)
orderSourceContainer.layer.cornerRadius = AppRadius.sm orderSourceEntryView.onImageTap = { [weak self] images, index in
orderSourceContainer.layer.borderWidth = 1 self?.actions.onOrderSourceImageTap?(images, index)
orderSourceContainer.layer.borderColor = OrderTokens.sourceBorder.cgColor
orderSourceButton.setTitleColor(AppColor.primary, for: .normal)
orderSourceButton.titleLabel?.font = .app(.body)
orderSourceButton.contentHorizontalAlignment = .leading
orderSourceButton.addTarget(self, action: #selector(orderSourceTapped), for: .touchUpInside)
orderSourceContainer.addSubview(orderSourceButton)
orderSourceButton.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.sm)
} }
actionRow.axis = .horizontal actionRow.axis = .horizontal
@ -211,7 +205,7 @@ final class OrderCardCell: UITableViewCell {
headerRow, headerRow,
createdAtLabel, createdAtLabel,
typeChip, typeChip,
orderSourceContainer, orderSourceEntryView,
actionRow, actionRow,
uploadTaskButton, uploadTaskButton,
editOptionView, editOptionView,

View File

@ -6,23 +6,58 @@
import SnapKit import SnapKit
import UIKit import UIKit
/// /// Bottom Sheet Android `OrderSourcePicker`
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
var onPersonSelected: ((OrderSourcePerson) -> Void)? private enum MenuLevel {
var onLeadSelected: ((OrderSourceSelection) -> Void)? case person
case lead
}
private let people: [OrderSourcePerson] private let orderNumber: String
private let leads: [OrderSourceLead] private let viewModel: OrderListViewModel
private let selectedPerson: OrderSourcePerson? private let onPersonSelected: (OrderSourcePerson) -> Void
private let tableView = UITableView(frame: .zero, style: .insetGrouped) private let onLeadBound: (OrderSourceSelection) -> Void
private var showingLeads = false private let onCallPhone: (String) -> Void
private let onPreviewImages: ([String], Int) -> Void
init(people: [OrderSourcePerson], leads: [OrderSourceLead], selectedPerson: OrderSourcePerson?) { private var level: MenuLevel = .person
self.people = people private var pendingPerson: OrderSourcePerson?
self.leads = leads
self.selectedPerson = selectedPerson private let headerStack = UIStackView()
self.showingLeads = !leads.isEmpty private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let leadHeaderRow = UIStackView()
private let backButton = UIButton(type: .system)
private let leadTitleLabel = UILabel()
private let leadPhoneButton = UIButton(type: .system)
private let leadCallButton = UIButton(type: .system)
private let tableView = UITableView(frame: .zero, style: .plain)
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let emptyLabel = UILabel()
private var referralOrder: BoundReferralOrderEntity?
init(
orderNumber: String,
viewModel: OrderListViewModel,
referralOrder: BoundReferralOrderEntity?,
onPersonSelected: @escaping (OrderSourcePerson) -> Void,
onLeadBound: @escaping (OrderSourceSelection) -> Void,
onCallPhone: @escaping (String) -> Void,
onPreviewImages: @escaping ([String], Int) -> Void
) {
self.orderNumber = orderNumber
self.viewModel = viewModel
self.onPersonSelected = onPersonSelected
self.onLeadBound = onLeadBound
self.onCallPhone = onCallPhone
self.onPreviewImages = onPreviewImages
self.referralOrder = referralOrder
self.pendingPerson = viewModel.orderSourceSelection(
orderNumber: orderNumber,
referralOrder: referralOrder
)?.person
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -33,44 +68,210 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
title = showingLeads ? "选择带客单" : "选择获客员" view.backgroundColor = .white
view.backgroundColor = UIColor(hex: 0xF5F5F5) setupUI()
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped)) applyViewModel()
tableView.dataSource = self
tableView.delegate = self
view.addSubview(tableView)
tableView.snp.makeConstraints { $0.edges.equalToSuperview() }
} }
@objc private func closeTapped() { func reloadFromViewModel() {
dismiss(animated: true) applyViewModel()
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = AppColor.text333
titleLabel.text = "选择获客员"
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.text666
subtitleLabel.text = "选择人员后,再选择该人员创建的带客单"
subtitleLabel.numberOfLines = 0
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = AppColor.text333
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
leadTitleLabel.font = .systemFont(ofSize: 18, weight: .bold)
leadTitleLabel.textColor = AppColor.text333
leadTitleLabel.numberOfLines = 2
leadPhoneButton.titleLabel?.font = .systemFont(ofSize: 13)
leadPhoneButton.setTitleColor(AppColor.primary, for: .normal)
leadPhoneButton.contentHorizontalAlignment = .leading
leadPhoneButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside)
leadCallButton.backgroundColor = OrderTokens.phoneIconBackground
leadCallButton.layer.cornerRadius = 16
leadCallButton.tintColor = AppColor.primary
leadCallButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
leadCallButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside)
leadHeaderRow.axis = .horizontal
leadHeaderRow.alignment = .top
leadHeaderRow.spacing = 10
leadHeaderRow.isHidden = true
let leadTextStack = UIStackView(arrangedSubviews: [leadTitleLabel, leadPhoneButton])
leadTextStack.axis = .vertical
leadTextStack.spacing = 3
leadHeaderRow.addArrangedSubview(backButton)
leadHeaderRow.addArrangedSubview(leadTextStack)
leadHeaderRow.addArrangedSubview(leadCallButton)
headerStack.axis = .vertical
headerStack.spacing = 5
headerStack.addArrangedSubview(titleLabel)
headerStack.addArrangedSubview(subtitleLabel)
headerStack.addArrangedSubview(leadHeaderRow)
tableView.separatorStyle = .none
tableView.backgroundColor = .white
tableView.dataSource = self
tableView.delegate = self
tableView.register(OrderSourcePersonCell.self, forCellReuseIdentifier: OrderSourcePersonCell.reuseIdentifier)
tableView.register(OrderSourceLeadCell.self, forCellReuseIdentifier: OrderSourceLeadCell.reuseIdentifier)
loadingIndicator.hidesWhenStopped = true
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.text666
emptyLabel.textAlignment = .center
emptyLabel.numberOfLines = 0
view.addSubview(headerStack)
view.addSubview(tableView)
view.addSubview(loadingIndicator)
view.addSubview(emptyLabel)
headerStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
backButton.snp.makeConstraints { make in
make.width.height.equalTo(28)
}
leadCallButton.snp.makeConstraints { make in
make.width.height.equalTo(32)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(headerStack.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalTo(tableView)
}
emptyLabel.snp.makeConstraints { make in
make.center.equalTo(tableView)
make.leading.trailing.equalTo(tableView).inset(AppSpacing.lg)
}
}
private func applyViewModel() {
let currentSelection = viewModel.orderSourceSelection(
orderNumber: orderNumber,
referralOrder: referralOrder
)
titleLabel.isHidden = level == .lead
subtitleLabel.isHidden = level == .lead
leadHeaderRow.isHidden = level == .person
if level == .lead, let person = pendingPerson {
leadTitleLabel.text = "\(person.name)创建的带客单"
let phone = person.phone.trimmingCharacters(in: .whitespacesAndNewlines)
leadPhoneButton.setTitle(phone, for: .normal)
leadPhoneButton.isHidden = phone.isEmpty
leadCallButton.isHidden = phone.isEmpty
}
let isLoadingPeople = viewModel.isLoadingOrderSourcePeople && level == .person
let isLoadingLeads = viewModel.loadingOrderSourceLeadsFor != nil && level == .lead
if isLoadingPeople || isLoadingLeads {
loadingIndicator.startAnimating()
tableView.isHidden = true
emptyLabel.isHidden = true
} else {
loadingIndicator.stopAnimating()
tableView.isHidden = false
let isEmpty: Bool
switch level {
case .person:
isEmpty = viewModel.orderSourcePeople.isEmpty
case .lead:
isEmpty = leadsForPendingPerson.isEmpty
}
emptyLabel.isHidden = !isEmpty
emptyLabel.text = level == .person ? "暂无合作获客员" : "暂无可绑定带客单"
tableView.reloadData()
}
_ = currentSelection
}
private var leadsForPendingPerson: [OrderSourceLead] {
guard let person = pendingPerson else { return [] }
return viewModel.orderSourceLeads[person.key] ?? []
}
@objc private func backTapped() {
level = .person
applyViewModel()
}
@objc private func leadPhoneTapped() {
guard let phone = pendingPerson?.phone.trimmingCharacters(in: .whitespacesAndNewlines), !phone.isEmpty else { return }
onCallPhone(phone)
} }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
showingLeads ? leads.count : people.count switch level {
case .person:
return viewModel.orderSourcePeople.count
case .lead:
return leadsForPendingPerson.count
}
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) let currentSelection = viewModel.orderSourceSelection(
if showingLeads { orderNumber: orderNumber,
let lead = leads[indexPath.row] referralOrder: referralOrder
cell.textLabel?.text = lead.displayText )
cell.detailTextLabel?.text = lead.userMobile switch level {
} else { case .person:
let person = people[indexPath.row] let cell = tableView.dequeueReusableCell(
cell.textLabel?.text = person.name withIdentifier: OrderSourcePersonCell.reuseIdentifier,
cell.detailTextLabel?.text = person.phone for: indexPath
) as! OrderSourcePersonCell
let person = viewModel.orderSourcePeople[indexPath.row]
cell.configure(
person: person,
selected: currentSelection?.person.key == person.key
)
cell.onCallPhone = onCallPhone
return cell
case .lead:
let cell = tableView.dequeueReusableCell(
withIdentifier: OrderSourceLeadCell.reuseIdentifier,
for: indexPath
) as! OrderSourceLeadCell
let lead = leadsForPendingPerson[indexPath.row]
let selected = currentSelection?.person.key == pendingPerson?.key
&& currentSelection?.lead.key == lead.key
cell.configure(lead: lead, selected: selected)
cell.onCallPhone = onCallPhone
cell.onImageTap = onPreviewImages
cell.onSelect = { [weak self] in
guard let self, let person = self.pendingPerson else { return }
let selection = OrderSourceSelection(person: person, lead: lead)
self.onLeadBound(selection)
}
return cell
} }
return cell
} }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if showingLeads, let person = selectedPerson { guard level == .person else { return }
let lead = leads[indexPath.row] let person = viewModel.orderSourcePeople[indexPath.row]
onLeadSelected?(OrderSourceSelection(person: person, lead: lead)) pendingPerson = person
dismiss(animated: true) level = .lead
} else { onPersonSelected(person)
onPersonSelected?(people[indexPath.row]) applyViewModel()
}
} }
} }

View File

@ -20,7 +20,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
private var hasInitialized = false private var hasInitialized = false
private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin } private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin }
private var pendingOrderSourceNumber: String? private var pendingOrderSourceNumber: String?
private var pendingOrderSourcePerson: OrderSourcePerson? private weak var orderSourcePicker: OrderSourcePickerViewController?
private var pendingGiftOrderNumber: String? private var pendingGiftOrderNumber: String?
private var isFetchingMultiVerifySpots = false private var isFetchingMultiVerifySpots = false
private let locationProvider = OrderLocationProvider() private let locationProvider = OrderLocationProvider()
@ -51,11 +51,17 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
override func bindActions() { override func bindActions() {
orderListViewModel.onStateChange = { [weak self] in orderListViewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() } Task { @MainActor in
self?.applyViewModel()
self?.orderSourcePicker?.reloadFromViewModel()
}
} }
orderListViewModel.onShowMessage = { [weak self] message in orderListViewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) } Task { @MainActor in self?.showToast(message) }
} }
orderListViewModel.onNavigateBindAcquirer = { [weak self] saleUserId in
Task { @MainActor in self?.presentBindAcquirer(saleUserId: saleUserId) }
}
depositViewModel.onStateChange = { [weak self] in depositViewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() } Task { @MainActor in self?.applyViewModel() }
} }
@ -259,6 +265,13 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
present(nav, animated: true) present(nav, animated: true)
} }
private func presentBindAcquirer(saleUserId: Int) {
dismiss(animated: true) { [weak self] in
let controller = BindAcquirerViewController(saleUserId: saleUserId)
self?.navigationController?.pushViewController(controller, animated: true)
}
}
private func fetchMultiVerifySpotsIfNeeded() async { private func fetchMultiVerifySpotsIfNeeded() async {
isFetchingMultiVerifySpots = true isFetchingMultiVerifySpots = true
defer { isFetchingMultiVerifySpots = false } defer { isFetchingMultiVerifySpots = false }
@ -322,7 +335,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
hideWriteActions: orderListViewModel.isScenicAdminReadOnly, hideWriteActions: orderListViewModel.isScenicAdminReadOnly,
actions: makeOrderActions(for: order), actions: makeOrderActions(for: order),
showOrderSource: orderListViewModel.shouldShowOrderSource(for: order), showOrderSource: orderListViewModel.shouldShowOrderSource(for: order),
sourceText: source.map { "\($0.person.name) · \($0.lead.displayText)" } sourceSelection: source
) )
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: orderListViewModel.shouldLoadMore(at: indexPath.row)) listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: orderListViewModel.shouldLoadMore(at: indexPath.row))
return cell return cell
@ -363,6 +376,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
actions.onOrderSourceTap = { [weak self] in actions.onOrderSourceTap = { [weak self] in
self?.presentOrderSourcePicker(for: order) self?.presentOrderSourcePicker(for: order)
} }
actions.onOrderSourceImageTap = { [weak self] images, index in
self?.presentImagePreview(images: images, startIndex: index)
}
if let travel = order.photoTravel, travel.canGiftRetouch { if let travel = order.photoTravel, travel.canGiftRetouch {
actions.onSendGift = { [weak self] in actions.onSendGift = { [weak self] in
guard let self else { return } guard let self else { return }
@ -451,23 +467,23 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
pendingOrderSourceNumber = order.orderNumber pendingOrderSourceNumber = order.orderNumber
Task { Task {
await orderListViewModel.onOrderSourceSheetOpen(api: orderAPI) await orderListViewModel.onOrderSourceSheetOpen(api: orderAPI)
let controller = OrderSourcePickerViewController( await MainActor.run { [weak self] in
people: orderListViewModel.orderSourcePeople,
leads: [],
selectedPerson: nil
)
controller.onPersonSelected = { [weak self] person in
guard let self else { return } guard let self else { return }
self.pendingOrderSourcePerson = person let controller = OrderSourcePickerViewController(
Task { orderNumber: order.orderNumber,
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI) viewModel: self.orderListViewModel,
let leadsController = OrderSourcePickerViewController( referralOrder: order.referralOrder,
people: self.orderListViewModel.orderSourcePeople, onPersonSelected: { [weak self] person in
leads: self.orderListViewModel.orderSourceLeads[person.key] ?? [], guard let self else { return }
selectedPerson: person Task {
) await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
leadsController.onLeadSelected = { [weak self] selection in }
},
onLeadBound: { [weak self] selection in
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return } guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
self.dismiss(animated: true) {
self.orderSourcePicker = nil
}
Task { Task {
await self.orderListViewModel.bindOrderSource( await self.orderListViewModel.bindOrderSource(
orderNumber: orderNumber, orderNumber: orderNumber,
@ -475,11 +491,44 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
api: self.orderAPI api: self.orderAPI
) )
} }
},
onCallPhone: { [weak self] phone in
self?.callPhone(phone)
},
onPreviewImages: { [weak self] urls, index in
self?.presentImagePreview(images: urls, startIndex: index)
}
)
self.orderSourcePicker = controller
let nav = UINavigationController(rootViewController: controller)
nav.setNavigationBarHidden(true, animated: false)
if let sheet = nav.sheetPresentationController {
sheet.prefersGrabberVisible = true
if #available(iOS 16.0, *) {
sheet.detents = [
.custom(identifier: .init("orderSource")) { context in
context.maximumDetentValue * 0.82
},
.large(),
]
sheet.selectedDetentIdentifier = .init("orderSource")
} else {
sheet.detents = [.large()]
} }
self.present(leadsController, animated: true)
} }
self.present(nav, animated: true)
} }
present(controller, animated: true)
} }
} }
private func callPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel://\(trimmed)") else { return }
UIApplication.shared.open(url)
}
private func presentImagePreview(images: [String], startIndex: Int) {
guard !images.isEmpty else { return }
present(ImagePreviewViewController(imageURLs: images, startIndex: startIndex), animated: true)
}
} }

View 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)
}
}
}

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

View 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)
}
}

View 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)
}
}

View File

@ -22,6 +22,10 @@ final class ProfileInfoRowView: UIControl {
private let divider = UIView() private let divider = UIView()
private let badgeContainer = UIView() private let badgeContainer = UIView()
private var badgeView: ProfileStatusBadgeView? private var badgeView: ProfileStatusBadgeView?
private let rightStack = UIStackView()
private let stackNameLabel = UILabel()
private let stackTypeTag = UILabel()
private var rowHeightConstraint: Constraint?
var showsDivider = true { var showsDivider = true {
didSet { divider.isHidden = !showsDivider } didSet { divider.isHidden = !showsDivider }
@ -44,6 +48,8 @@ final class ProfileInfoRowView: UIControl {
accessory: AccessoryStyle = .none, accessory: AccessoryStyle = .none,
badge: ProfileStatusBadgeView.Style? = nil badge: ProfileStatusBadgeView.Style? = nil
) { ) {
setStackedModeEnabled(false)
valueLabel.text = value valueLabel.text = value
valueLabel.textColor = valueColor valueLabel.textColor = valueColor
valueLabel.isHidden = badge != nil valueLabel.isHidden = badge != nil
@ -73,6 +79,84 @@ final class ProfileInfoRowView: UIControl {
accessoryLabel.isHidden = false accessoryLabel.isHidden = false
chevronView.isHidden = false chevronView.isHidden = false
} }
updateContentConstraints(for: accessory)
}
///
func configureAccountInfo(
storeName: String,
accountTypeLabel: String,
isStoreAccount: Bool,
showsChevron: Bool = true
) {
setStackedModeEnabled(true, showsChevron: showsChevron)
stackNameLabel.text = storeName
stackTypeTag.text = accountTypeLabel
if isStoreAccount {
stackTypeTag.textColor = UIColor(hex: 0x0F9F6E)
stackTypeTag.backgroundColor = UIColor(hex: 0xE8F8F1)
} else {
stackTypeTag.textColor = UIColor(hex: 0x7C3AED)
stackTypeTag.backgroundColor = UIColor(hex: 0xF3ECFF)
}
updateStackedConstraints(showsChevron: showsChevron)
}
private func setStackedModeEnabled(_ enabled: Bool, showsChevron: Bool = false) {
rightStack.isHidden = !enabled
accessoryLabel.isHidden = true
chevronView.isHidden = !enabled || !showsChevron
if enabled {
valueLabel.isHidden = true
badgeContainer.isHidden = true
rowHeightConstraint?.update(offset: 64)
} else {
rowHeightConstraint?.update(offset: 52)
}
}
private func updateStackedConstraints(showsChevron: Bool) {
rightStack.snp.remakeConstraints { make in
make.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
if showsChevron {
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
} else {
make.trailing.equalToSuperview()
}
}
}
private func updateContentConstraints(for accessory: AccessoryStyle) {
valueLabel.snp.remakeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalToSuperview()
switch accessory {
case .action:
make.trailing.equalTo(accessoryLabel.snp.leading).offset(-8)
case .chevron:
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
case .none:
make.trailing.equalToSuperview()
}
}
badgeContainer.snp.remakeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalToSuperview()
switch accessory {
case .action:
make.trailing.lessThanOrEqualTo(accessoryLabel.snp.leading).offset(-8)
case .chevron:
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
case .none:
make.trailing.lessThanOrEqualToSuperview()
}
}
} }
private func setupUI() { private func setupUI() {
@ -83,13 +167,36 @@ final class ProfileInfoRowView: UIControl {
valueLabel.font = .systemFont(ofSize: 14) valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textAlignment = .right valueLabel.textAlignment = .right
valueLabel.numberOfLines = 1 valueLabel.numberOfLines = 1
valueLabel.lineBreakMode = .byTruncatingTail
valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
accessoryLabel.font = .systemFont(ofSize: 14) accessoryLabel.font = .systemFont(ofSize: 14)
accessoryLabel.textColor = AppColor.primary accessoryLabel.textColor = AppColor.primary
accessoryLabel.setContentHuggingPriority(.required, for: .horizontal)
accessoryLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
chevronView.image = UIImage(systemName: "chevron.right") chevronView.image = UIImage(systemName: "chevron.right")
chevronView.tintColor = AppColor.text666 chevronView.tintColor = AppColor.text666
chevronView.contentMode = .scaleAspectFit chevronView.contentMode = .scaleAspectFit
chevronView.setContentHuggingPriority(.required, for: .horizontal)
stackNameLabel.font = .systemFont(ofSize: 14)
stackNameLabel.textColor = AppColor.text333
stackNameLabel.textAlignment = .right
stackNameLabel.numberOfLines = 1
stackNameLabel.lineBreakMode = .byTruncatingTail
stackTypeTag.font = .systemFont(ofSize: 11, weight: .semibold)
stackTypeTag.textAlignment = .center
stackTypeTag.layer.cornerRadius = 4
stackTypeTag.clipsToBounds = true
rightStack.axis = .vertical
rightStack.alignment = .trailing
rightStack.spacing = 4
rightStack.addArrangedSubview(stackNameLabel)
rightStack.addArrangedSubview(stackTypeTag)
rightStack.isHidden = true
divider.backgroundColor = UIColor(hex: 0xE5E7EB) divider.backgroundColor = UIColor(hex: 0xE5E7EB)
@ -98,10 +205,11 @@ final class ProfileInfoRowView: UIControl {
addSubview(badgeContainer) addSubview(badgeContainer)
addSubview(accessoryLabel) addSubview(accessoryLabel)
addSubview(chevronView) addSubview(chevronView)
addSubview(rightStack)
addSubview(divider) addSubview(divider)
snp.makeConstraints { make in snp.makeConstraints { make in
make.height.equalTo(52) rowHeightConstraint = make.height.equalTo(52).constraint
} }
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview() make.leading.equalToSuperview()
@ -117,16 +225,16 @@ final class ProfileInfoRowView: UIControl {
make.trailing.equalTo(chevronView.snp.leading).offset(-4) make.trailing.equalTo(chevronView.snp.leading).offset(-4)
make.centerY.equalToSuperview() make.centerY.equalToSuperview()
} }
valueLabel.snp.makeConstraints { make in rightStack.snp.makeConstraints { make in
make.trailing.equalToSuperview()
make.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8) make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
make.centerY.equalToSuperview()
} }
badgeContainer.snp.makeConstraints { make in stackTypeTag.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8) make.height.equalTo(22)
make.centerY.equalToSuperview() make.width.greaterThanOrEqualTo(56)
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
} }
updateContentConstraints(for: .none)
divider.snp.makeConstraints { make in divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5) make.height.equalTo(0.5)

View File

@ -31,6 +31,7 @@ final class ProfileViewController: BaseViewController {
private let statusRow = ProfileInfoRowView(title: "账号状态") private let statusRow = ProfileInfoRowView(title: "账号状态")
private let scenicRow = ProfileInfoRowView(title: "当前景区") private let scenicRow = ProfileInfoRowView(title: "当前景区")
private let logoutButton = UIButton(type: .system) private let logoutButton = UIButton(type: .system)
private var hasLoadedProfileOnce = false
override func setupNavigationBar() { override func setupNavigationBar() {
title = "我的" title = "我的"
@ -101,7 +102,13 @@ final class ProfileViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
Task { await reloadProfile(showGlobalLoading: true) } let showLoading = !hasLoadedProfileOnce
Task {
await reloadProfile(showGlobalLoading: showLoading)
if showLoading {
hasLoadedProfileOnce = true
}
}
} }
private let headerContainer = UIView() private let headerContainer = UIView()
@ -194,9 +201,10 @@ final class ProfileViewController: BaseViewController {
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL) avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
nameRow.configure(value: viewModel.displayRealName) nameRow.configure(value: viewModel.displayRealName)
accountRow.configure( accountRow.configureAccountInfo(
value: viewModel.accountDisplayName, storeName: viewModel.accountDisplayName,
accessory: .action("切换账号") accountTypeLabel: viewModel.accountTypeLabel,
isStoreAccount: viewModel.isStoreAccount
) )
phoneRow.configure(value: viewModel.displayPhone) phoneRow.configure(value: viewModel.displayPhone)

View 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)))
}
}

View 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)))
}
}

View 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))
}
}

View 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%(固定)")
}
}

View File

@ -148,4 +148,27 @@ final class HomeViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"]) XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"])
XCTAssertEqual(session.requests.count, 1) XCTAssertEqual(session.requests.count, 1)
} }
func testRefreshLocalDisplayStateClearsWhenNameMissing() {
appStore.currentScenicId = 10
appStore.currentScenicName = ""
let viewModel = HomeViewModel(appStore: appStore)
viewModel.refreshLocalDisplayState()
XCTAssertEqual(viewModel.currentScenicName, "")
XCTAssertEqual(appStore.currentScenicId, 0)
XCTAssertEqual(appStore.currentScenicName, "")
}
func testRefreshLocalDisplayStateClearsWhenIdMissing() {
appStore.currentScenicId = 0
appStore.currentScenicName = "残留名称"
let viewModel = HomeViewModel(appStore: appStore)
viewModel.refreshLocalDisplayState()
XCTAssertEqual(viewModel.currentScenicName, "")
XCTAssertEqual(appStore.currentScenicName, "")
}
} }

View File

@ -166,4 +166,69 @@ final class OrderListViewModelTests: XCTestCase {
XCTAssertEqual(list[1].photoTravel?.orderPhotoNum, 6) XCTAssertEqual(list[1].photoTravel?.orderPhotoNum, 6)
XCTAssertEqual(list[1].photoTravel?.needCheckIn, true) XCTAssertEqual(list[1].photoTravel?.needCheckIn, true)
} }
func testReferralOrderEntityDecodesImages() throws {
let json = """
{
"id": 1,
"referral_order_no": "LD001",
"order_no": "LD001",
"user_mobile": "13800138000",
"remark": "",
"created_at": "2026-06-25",
"status": 1,
"status_label": "",
"images": ["https://cdn.example.com/a.jpg", "https://cdn.example.com/b.jpg"]
}
""".data(using: .utf8)!
let entity = try JSONDecoder().decode(ReferralOrderEntity.self, from: json)
XCTAssertEqual(entity.images.count, 2)
XCTAssertEqual(OrderSourceMapping.lead(from: entity).remark, "")
}
func testOrderSourceLeadMappingUsesStatusLabelFallback() throws {
let json = """
{
"id": 1,
"referral_order_no": "LD002",
"order_no": "LD002",
"user_mobile": "13800138000",
"remark": "",
"created_at": "",
"status": 1,
"status_label": "已完成",
"images": []
}
""".data(using: .utf8)!
let entity = try JSONDecoder().decode(ReferralOrderEntity.self, from: json)
XCTAssertEqual(OrderSourceMapping.lead(from: entity).remark, "")
}
func testOrderSourceSelectionFromBoundReferralUsesRemarkFallback() throws {
let json = """
{
"id": 53,
"order_no": "LD202606250008",
"sale_user_id": 6,
"saler_name": "管铜彬",
"photog_remark_name": "管铜彬1234567",
"saler_phone": "19281871413",
"display_text": "管铜彬1234567 19281871413",
"user_mobile": "13333333331",
"remark": "",
"bind_order_number": "260625128009",
"source_type": 2,
"source_type_label": "下级线索",
"status": 2,
"status_label": "已完成",
"created_at": "2026-06-25 17:13:51"
}
""".data(using: .utf8)!
let referral = try JSONDecoder().decode(BoundReferralOrderEntity.self, from: json)
let selection = OrderSourceMapping.selection(from: referral)
XCTAssertEqual(selection?.person.name, "1234567")
XCTAssertEqual(selection?.lead.remark, "1234567 19281871413")
XCTAssertEqual(selection?.lead.key, "LD202606250008")
}
} }

View 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)
}
}

View 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")
}
}

View File

@ -44,6 +44,22 @@ final class ProfileViewModelTests: XCTestCase {
} }
} }
func testAccountTypeLabelForStoreAccount() {
AppStore.shared.accountType = V9StoreUser.accountTypeValue
let viewModel = ProfileViewModel()
XCTAssertEqual(viewModel.accountTypeLabel, "门店账号")
XCTAssertTrue(viewModel.isStoreAccount)
AppStore.shared.accountType = ""
}
func testAccountTypeLabelForScenicAccount() {
AppStore.shared.accountType = V9ScenicUser.accountTypeValue
let viewModel = ProfileViewModel()
XCTAssertEqual(viewModel.accountTypeLabel, "景区账号")
XCTAssertFalse(viewModel.isStoreAccount)
AppStore.shared.accountType = ""
}
func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() { func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() {
AppStore.shared.userName = "全局昵称" AppStore.shared.userName = "全局昵称"
AppStore.shared.avatar = "https://cdn.example.com/new.jpg" AppStore.shared.avatar = "https://cdn.example.com/new.jpg"

View 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")
}
}

View 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")
}
}