迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。

新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 13:33:00 +08:00
parent d310d26293
commit 1970572edd
66 changed files with 4247 additions and 201 deletions

View File

@ -11,6 +11,7 @@ import SwiftUI
struct StoreOrderDetailView: View {
@EnvironmentObject private var accountContext: AccountContext
@Environment(\.ordersAPI) private var ordersAPI
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@EnvironmentObject private var router: RouterPath
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@ -190,6 +191,11 @@ struct StoreOrderDetailView: View {
private func submitRefund() async {
let success = await refundViewModel.submit(api: ordersAPI, item: item)
guard success else { return }
if let referralOrderId = item.referralOrder?.id, referralOrderId > 0 {
Task {
try? await cooperationOrderAPI.saleUserUpdateReferralOrderStatus(id: referralOrderId, status: 3)
}
}
showRefundSheet = false
toastCenter.show("退款申请已提交")
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id, forceReload: true)

View File

@ -0,0 +1,365 @@
import SwiftUI
import Kingfisher
enum OrderSourceColors {
static let primary = Color(hex: 0x0073FF)
static let text333 = Color(hex: 0x333333)
static let text563 = Color(hex: 0x4B5563)
static let text666 = Color(hex: 0x666666)
static let text999 = Color(hex: 0x999999)
static let border = Color(hex: 0xE6EAF0)
static let divider = Color(hex: 0xE9EDF3)
static let pageBg = Color(hex: 0xF5F7FA)
static let primarySoft = Color(hex: 0xEFF6FF)
static let imageEmptyBg = Color(hex: 0xF3F5F8)
static let entryBg = Color(hex: 0xF9FAFC)
}
struct AcquirerCallButton: View {
let phone: String
let onCall: (String) -> Void
var body: some View {
Button {
onCall(phone)
} label: {
Image(systemName: "phone.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(OrderSourceColors.primary)
.frame(width: 32, height: 32)
.background(OrderSourceColors.primarySoft, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("拨打\(phone)")
}
}
struct SourcePersonRow: View {
let person: OrderSourcePerson
let selected: Bool
let onCall: (String) -> Void
let onTap: () -> Void
var body: some View {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading, spacing: 0) {
Text(person.name)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(OrderSourceColors.text333)
if !person.phone.isEmpty {
Text(person.phone)
.font(.system(size: 14))
.foregroundStyle(OrderSourceColors.text666)
.padding(.top, 6)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if !person.phone.isEmpty {
AcquirerCallButton(phone: person.phone, onCall: onCall)
}
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(OrderSourceColors.text666)
.frame(width: 20, height: 20)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.background(
selected ? OrderSourceColors.primary.opacity(0.05) : Color.white,
in: RoundedRectangle(cornerRadius: 8)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(selected ? OrderSourceColors.primary : OrderSourceColors.border, lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture(perform: onTap)
}
}
struct SourceLeadContent: View {
let lead: OrderSourceLead
var compact = false
var enablePhoneCall = true
let onCall: (String) -> Void
let onImageTap: ([String], Int) -> Void
var body: some View {
VStack(alignment: .leading, spacing: compact ? 6 : 10) {
if !lead.userPhone.isEmpty {
HStack(alignment: .center, spacing: 0) {
Text("客户手机号")
.font(.system(size: 12))
.foregroundStyle(OrderSourceColors.text666)
Text(lead.userPhone)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(enablePhoneCall ? OrderSourceColors.primary : OrderSourceColors.text333)
.padding(.leading, 10)
.onTapGesture {
if enablePhoneCall {
onCall(lead.userPhone)
}
}
}
}
if !lead.remark.isEmpty {
Text(lead.remark)
.font(.system(size: compact ? 12 : 13))
.foregroundStyle(OrderSourceColors.text333)
.lineSpacing(compact ? 4 : 6)
.lineLimit(compact ? 2 : 4)
}
if lead.images.isEmpty {
if !compact {
Text("暂无图片")
.font(.system(size: 12))
.foregroundStyle(OrderSourceColors.text666)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(OrderSourceColors.imageEmptyBg, in: RoundedRectangle(cornerRadius: 6))
}
} else {
HStack(spacing: 8) {
ForEach(Array(lead.images.prefix(3).enumerated()), id: \.offset) { index, imageUrl in
leadThumbnail(imageUrl: imageUrl, index: index)
}
ForEach(0..<max(0, 3 - min(lead.images.count, 3)), id: \.self) { _ in
Color.clear
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity)
}
}
}
}
}
private func leadThumbnail(imageUrl: String, index: Int) -> some View {
Group {
if let url = URL(string: imageUrl), !imageUrl.isEmpty {
KFImage(url)
.resizable()
.scaledToFill()
} else {
OrderSourceColors.imageEmptyBg
}
}
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
.onTapGesture {
onImageTap(lead.images, index)
}
}
}
struct SourceLeadCard: View {
let lead: OrderSourceLead
let selected: Bool
let onCall: (String) -> Void
let onImageTap: ([String], Int) -> Void
let onSelect: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center) {
Text("带客单")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(OrderSourceColors.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(OrderSourceColors.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4))
Spacer()
if selected {
Image(systemName: "checkmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(OrderSourceColors.primary)
.frame(width: 20, height: 20)
}
}
SourceLeadContent(
lead: lead,
compact: false,
enablePhoneCall: true,
onCall: onCall,
onImageTap: onImageTap
)
Text(selected ? "当前已选择" : "选择此带客单")
.font(.system(size: 14))
.foregroundStyle(selected ? OrderSourceColors.primary : .white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
selected ? OrderSourceColors.primary.opacity(0.1) : OrderSourceColors.primary,
in: RoundedRectangle(cornerRadius: 6)
)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
}
.padding(12)
.background(
selected ? OrderSourceColors.primary.opacity(0.04) : Color.white,
in: RoundedRectangle(cornerRadius: 8)
)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(selected ? OrderSourceColors.primary : OrderSourceColors.border, lineWidth: 1)
)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
}
}
struct AcquirerSelectCard: View {
let name: String
let phone: String
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 0) {
Text(name)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(OrderSourceColors.text333)
if !phone.isEmpty {
Text(phone)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
.padding(.top, 6)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
}
struct ReferralOrderSelectCard: View {
let order: ReferralOrder
let selected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 8) {
Text("带客单号 \(order.displayReferralOrderNo)")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(OrderSourceColors.text333)
if !order.statusLabel.isEmpty {
Text(order.statusLabel)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
}
Text("用户 \(order.userMobile.isEmpty ? "" : order.userMobile)")
.font(.system(size: 12))
.foregroundStyle(OrderSourceColors.text999)
if !order.remark.isEmpty {
Text(order.remark)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
}
if !order.createdAt.isEmpty {
Text(order.createdAt)
.font(.system(size: 11))
.foregroundStyle(OrderSourceColors.text999)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(selected ? OrderSourceColors.primary : Color.clear, lineWidth: 1.5)
)
}
.buttonStyle(.plain)
}
}
struct SimpleImagePreviewContent: View {
let imageUrls: [String]
@Binding var currentIndex: Int
var body: some View {
TabView(selection: $currentIndex) {
ForEach(Array(imageUrls.enumerated()), id: \.offset) { index, urlString in
Group {
if let url = URL(string: urlString), !urlString.isEmpty {
KFImage(url)
.resizable()
.scaledToFit()
} else {
Text("无法加载图片")
.foregroundStyle(OrderSourceColors.text666)
}
}
.tag(index)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
}
}
.tabViewStyle(.page(indexDisplayMode: imageUrls.count > 1 ? .automatic : .never))
.background(Color.black.ignoresSafeArea())
}
}
struct SimpleImagePreviewSheet: View {
let imageUrls: [String]
let startIndex: Int
let onDismiss: () -> Void
@State private var currentIndex: Int
init(imageUrls: [String], startIndex: Int, onDismiss: @escaping () -> Void) {
self.imageUrls = imageUrls
self.startIndex = startIndex
self.onDismiss = onDismiss
_currentIndex = State(initialValue: startIndex)
}
var body: some View {
NavigationStack {
SimpleImagePreviewContent(imageUrls: imageUrls, currentIndex: $currentIndex)
.navigationTitle("图片预览")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭", action: onDismiss)
}
}
}
}
}
struct SimpleImagePreviewScreen: View {
let imageUrls: [String]
let startIndex: Int
@State private var currentIndex: Int
init(imageUrls: [String], startIndex: Int) {
self.imageUrls = imageUrls
self.startIndex = startIndex
_currentIndex = State(initialValue: startIndex)
}
var body: some View {
SimpleImagePreviewContent(imageUrls: imageUrls, currentIndex: $currentIndex)
.navigationTitle("图片预览")
.navigationBarTitleDisplayMode(.inline)
}
}

View File

@ -0,0 +1,227 @@
import SwiftUI
struct OrderSourcePicker: View {
let selection: OrderSourceSelection?
let people: [OrderSourcePerson]
let leadsForPerson: (OrderSourcePerson) -> [OrderSourceLead]
let leadsLoading: Bool
let onSheetOpen: () -> Void
let onPersonClick: (OrderSourcePerson) -> Void
let onSelectionChange: (OrderSourceSelection) -> Void
let callPhone: (String) -> Void
@State private var sheetVisible = false
@State private var navigationPath = NavigationPath()
@State private var previewImages: [String] = []
@State private var previewIndex: Int?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text("订单来源")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(OrderSourceColors.text563)
if let selection {
HStack(spacing: 10) {
Text(selection.person.name)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(OrderSourceColors.text333)
if !selection.person.phone.isEmpty {
Text(selection.person.phone)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
}
}
} else {
Text("选择获客员及其创建的带客单")
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
}
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(OrderSourceColors.primary)
.frame(width: 20, height: 20)
}
if let selection {
Divider().overlay(OrderSourceColors.divider)
SourceLeadContent(
lead: selection.lead,
compact: true,
enablePhoneCall: false,
onCall: callPhone,
onImageTap: { images, index in
previewImages = images
previewIndex = index
}
)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(OrderSourceColors.entryBg, in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(OrderSourceColors.border, lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture {
openSheet()
}
.sheet(isPresented: $sheetVisible) {
sheetContainer
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.sheet(item: previewBinding) { item in
SimpleImagePreviewSheet(
imageUrls: item.urls,
startIndex: item.index,
onDismiss: { previewIndex = nil }
)
}
}
private func openSheet() {
onSheetOpen()
navigationPath = NavigationPath()
sheetVisible = true
}
private var previewBinding: Binding<ImagePreviewItem?> {
Binding(
get: {
guard let previewIndex else { return nil }
return ImagePreviewItem(urls: previewImages, index: previewIndex)
},
set: { newValue in
if newValue == nil { previewIndex = nil }
}
)
}
private var sheetContainer: some View {
NavigationStack(path: $navigationPath) {
personSheetContent
.navigationDestination(for: OrderSourcePerson.self) { person in
leadSheetContent(person: person)
}
.navigationDestination(for: OrderSourceImagePreviewRoute.self) { route in
SimpleImagePreviewScreen(
imageUrls: route.urls,
startIndex: route.startIndex
)
}
}
.background(Color.white)
}
private var personSheetContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("选择人员后,再选择该人员创建的带客单")
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
.padding(.bottom, 14)
if people.isEmpty {
Text("暂无合作获客员")
.font(.system(size: 14))
.foregroundStyle(OrderSourceColors.text666)
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else {
LazyVStack(spacing: 10) {
ForEach(people) { person in
SourcePersonRow(
person: person,
selected: selection?.person.key == person.key,
onCall: callPhone,
onTap: {
onPersonClick(person)
navigationPath.append(person)
}
)
}
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 24)
}
.navigationTitle("选择获客员")
.navigationBarTitleDisplayMode(.inline)
}
private func leadSheetContent(person: OrderSourcePerson) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if !person.phone.isEmpty {
HStack(spacing: 6) {
Text(person.phone)
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.primary)
.onTapGesture { callPhone(person.phone) }
AcquirerCallButton(phone: person.phone, onCall: callPhone)
}
.padding(.bottom, 14)
}
if leadsLoading {
ProgressView()
.tint(OrderSourceColors.primary)
.frame(maxWidth: .infinity)
.padding(.top, 32)
} else {
let leads = leadsForPerson(person)
if leads.isEmpty {
Text("暂无可绑定带客单")
.font(.system(size: 14))
.foregroundStyle(OrderSourceColors.text666)
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
} else {
LazyVStack(spacing: 12) {
ForEach(leads) { lead in
SourceLeadCard(
lead: lead,
selected: selection?.person.key == person.key && selection?.lead.key == lead.key,
onCall: callPhone,
onImageTap: { images, index in
navigationPath.append(
OrderSourceImagePreviewRoute(urls: images, startIndex: index)
)
},
onSelect: {
onSelectionChange(OrderSourceSelection(person: person, lead: lead))
sheetVisible = false
}
)
}
}
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 24)
}
.navigationTitle("\(person.name)创建的带客单")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct ImagePreviewItem: Identifiable {
let urls: [String]
let index: Int
var id: String { "\(index)-\(urls.joined())" }
}
private struct OrderSourceImagePreviewRoute: Hashable {
let urls: [String]
let startIndex: Int
}

View File

@ -14,9 +14,11 @@ struct OrdersView: View {
@EnvironmentObject private var appRouter: AppRouter
@EnvironmentObject private var router: RouterPath
@Environment(\.ordersAPI) private var ordersAPI
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = OrdersViewModel()
@State private var manualOrderNumber = ""
@ -27,6 +29,8 @@ struct OrdersView: View {
@State private var pendingVerifyOrderNumber: String?
@State private var matchedScannedOrderNumber: String?
@State private var scanHintMessage: String?
@State private var bindAcquirerSaleUserId: Int?
@State private var referralBindOrderNumber: String?
private var currentScenicId: Int? {
accountContext.currentScenic?.id
@ -36,8 +40,8 @@ struct OrdersView: View {
accountContext.currentStore?.id
}
private var currentRoleId: Int? {
permissionContext.currentRole?.id
private var currentAppRole: AppRoleCode? {
permissionContext.currentAppRole
}
private var contentMaxWidth: CGFloat {
@ -139,6 +143,32 @@ struct OrdersView: View {
onApply: { Task { await reload() } }
)
}
.fullScreenCover(item: bindAcquirerBinding) { item in
NavigationStack {
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
bindAcquirerSaleUserId = nil
})
}
}
.appNavigationDestination(item: referralBindBinding) { item in
ReferralOrderSelectView(orderNumber: item.orderNumber) {
Task { await reload(showLoading: false) }
}
}
}
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
Binding(
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
set: { bindAcquirerSaleUserId = $0?.saleUserId }
)
}
private var referralBindBinding: Binding<OrderNumberSheetItem?> {
Binding(
get: { referralBindOrderNumber.map(OrderNumberSheetItem.init(orderNumber:)) },
set: { referralBindOrderNumber = $0?.orderNumber }
)
}
private var entrySection: some View {
@ -413,6 +443,48 @@ struct OrdersView: View {
orderInfoRow("付款方式", item.payTypeName.isEmpty ? "--" : item.payTypeName)
orderInfoRow("手机号", maskPhoneNumber(item.phone))
orderInfoRow("关联项目", item.projectName.isEmpty ? "--" : item.projectName)
if viewModel.shouldShowOrderSource(order: item, appRole: currentAppRole) {
OrderSourcePicker(
selection: viewModel.getOrderSourceSelection(for: item),
people: viewModel.orderSourcePeople,
leadsForPerson: { person in
viewModel.orderSourceLeads[person.key] ?? []
},
leadsLoading: viewModel.loadingOrderSourceLeadsFor != nil,
onSheetOpen: {
Task {
await viewModel.prepareOrderSourceSheet(
api: cooperationOrderAPI,
storeId: currentStoreId
)
}
},
onPersonClick: { person in
Task {
await viewModel.loadOrderSourceLeads(api: cooperationOrderAPI, person: person)
}
},
onSelectionChange: { selection in
Task {
do {
try await viewModel.bindOrderSource(
api: cooperationOrderAPI,
orderNumber: item.orderNumber,
selection: selection
)
toastCenter.show("绑定成功")
await reload(showLoading: false)
} catch {
toastCenter.show(error.localizedDescription)
}
}
},
callPhone: { phone in
dialPhone(phone)
}
)
}
}
.padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
@ -522,7 +594,7 @@ struct OrdersView: View {
api: ordersAPI,
scenicId: currentScenicId,
storeId: currentStoreId,
roleId: currentRoleId,
appRole: currentAppRole,
showLoading: showLoading
)
}
@ -544,7 +616,7 @@ struct OrdersView: View {
private func loadMoreStoreOrders() async {
do {
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, appRole: currentAppRole)
} catch {
toastCenter.show(error.localizedDescription)
}
@ -595,15 +667,30 @@ struct OrdersView: View {
}
}
/// TabBar
/// TabBar
private func consumePendingScanCodeIfNeeded() async {
guard let rawCode = appRouter.consumePendingOrderScanCode() else { return }
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
toastCenter.show("请扫描正确的获客员二维码")
return
}
bindAcquirerSaleUserId = saleUserId
return
}
viewModel.selectedEntry = .verificationOrders
appRouter.selectOrders(entry: .verificationOrders)
await reload(showLoading: false)
handleScanResult(rawCode)
}
///
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
///
private func handleScanResult(_ rawCode: String) {
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {

View File

@ -0,0 +1,312 @@
//
// ReferralOrderSelectView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
import Combine
/// ViewModel
@MainActor
final class ReferralOrderSelectViewModel: ObservableObject {
enum Step {
case selectAcquirer
case selectReferralOrder
}
@Published var step: Step = .selectAcquirer
@Published var acquirers: [CooperativeSaler] = []
@Published var selectedAcquirer: CooperativeSaler?
@Published var referralOrders: [ReferralOrder] = []
@Published var selectedReferralOrder: ReferralOrder?
@Published var isLoadingMore = false
@Published var canLoadMore = false
@Published private(set) var hasLoadedAcquirersOnce = false
@Published private(set) var hasLoadedReferralOrdersOnce = false
private var orderNumber = ""
private var currentPage = 1
private var totalCount = 0
private var isReloadingReferralOrders = false
private let pageSize = 10
///
func initOrder(orderNumber: String, api: CooperationOrderServing, storeId: Int?) async {
if self.orderNumber == orderNumber && !acquirers.isEmpty { return }
self.orderNumber = orderNumber
await loadAcquirers(api: api, storeId: storeId)
}
private func loadAcquirers(api: CooperationOrderServing, storeId: Int?) async {
do {
let storeParam = storeId.flatMap { $0 > 0 ? $0 : nil }
let data = try await api.cooperativeSalers(storeId: storeParam, page: 1, pageSize: 50)
acquirers = data.list
if data.list.count == 1 {
await selectAcquirer(data.list[0], api: api)
} else {
step = .selectAcquirer
}
} catch {
if !hasLoadedAcquirersOnce {
acquirers = []
}
}
hasLoadedAcquirersOnce = true
}
///
func selectAcquirer(_ acquirer: CooperativeSaler, api: CooperationOrderServing) async {
selectedAcquirer = acquirer
selectedReferralOrder = nil
hasLoadedReferralOrdersOnce = false
step = .selectReferralOrder
await refreshReferralOrders(api: api)
}
///
func backToAcquirerStep() {
if acquirers.count <= 1 { return }
step = .selectAcquirer
selectedReferralOrder = nil
referralOrders = []
}
///
func selectReferralOrder(_ order: ReferralOrder) {
selectedReferralOrder = order
}
///
func refreshReferralOrders(api: CooperationOrderServing) async {
guard let acquirer = selectedAcquirer else { return }
guard !isReloadingReferralOrders else { return }
isReloadingReferralOrders = true
defer { isReloadingReferralOrders = false }
currentPage = 1
isLoadingMore = false
await loadReferralOrders(api: api, saleUserId: acquirer.displayId)
}
///
func loadMoreReferralOrders(api: CooperationOrderServing) async {
guard !isReloadingReferralOrders, !isLoadingMore, canLoadMore,
let acquirer = selectedAcquirer else { return }
isLoadingMore = true
currentPage += 1
await loadReferralOrders(api: api, saleUserId: acquirer.displayId)
}
private func loadReferralOrders(api: CooperationOrderServing, saleUserId: Int) async {
do {
let data = try await api.saleUserReferralOrders(
saleUserId: saleUserId,
page: currentPage,
pageSize: pageSize
)
totalCount = data.total
if currentPage == 1 {
referralOrders = data.list
} else {
referralOrders.append(contentsOf: data.list)
}
canLoadMore = referralOrders.count < totalCount
} catch {
if currentPage == 1, !hasLoadedReferralOrdersOnce {
referralOrders = []
}
if currentPage > 1 {
currentPage = max(currentPage - 1, 1)
}
}
if currentPage == 1 {
hasLoadedReferralOrdersOnce = true
}
isLoadingMore = false
}
///
func confirmBind(api: CooperationOrderServing) async throws {
guard let referralOrder = selectedReferralOrder else {
throw NSError(domain: "ReferralOrder", code: 1, userInfo: [NSLocalizedDescriptionKey: "请选择带客单"])
}
guard !orderNumber.isEmpty else {
throw NSError(domain: "ReferralOrder", code: 2, userInfo: [NSLocalizedDescriptionKey: "订单号无效"])
}
try await api.saleUserBindReferralOrder(
orderNumber: orderNumber,
referralOrderNo: referralOrder.displayReferralOrderNo
)
}
}
///
struct ReferralOrderSelectView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@Environment(\.globalLoading) private var globalLoading
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ReferralOrderSelectViewModel()
let orderNumber: String
let onBound: () -> Void
@State private var binding = false
var body: some View {
Group {
if viewModel.step == .selectAcquirer {
acquirerList
} else {
referralOrderList
}
}
.navigationTitle(viewModel.step == .selectAcquirer ? "选择获客员" : "选择带客单")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
if viewModel.step == .selectReferralOrder && viewModel.acquirers.count > 1 {
viewModel.backToAcquirerStep()
} else {
dismiss()
}
} label: {
Image(systemName: "chevron.backward")
}
}
}
.safeAreaInset(edge: .bottom) {
if viewModel.step == .selectReferralOrder {
Button {
Task { await confirmBind() }
} label: {
Text(binding ? "提交中..." : "确认绑定")
.font(.system(size: 16))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(
viewModel.selectedReferralOrder != nil
? OrderSourceColors.primary
: OrderSourceColors.primary.opacity(0.4),
in: RoundedRectangle(cornerRadius: 8)
)
}
.buttonStyle(.plain)
.disabled(viewModel.selectedReferralOrder == nil || binding)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.white)
}
}
.background(OrderSourceColors.pageBg.ignoresSafeArea())
.task {
await globalLoading.withLoading {
await viewModel.initOrder(
orderNumber: orderNumber,
api: cooperationOrderAPI,
storeId: accountContext.currentStore?.id
)
}
if viewModel.acquirers.isEmpty {
toastCenter.show("暂无合作获客员,请先绑定获客员")
dismiss()
}
}
}
private func selectAcquirer(_ acquirer: CooperativeSaler) async {
await globalLoading.withLoading {
await viewModel.selectAcquirer(acquirer, api: cooperationOrderAPI)
}
}
private var acquirerList: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
AcquirerSelectCard(
name: acquirer.displayName,
phone: acquirer.displayPhone
) {
Task { await selectAcquirer(acquirer) }
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
}
private var referralOrderList: some View {
ScrollView {
LazyVStack(spacing: 12) {
if let acquirer = viewModel.selectedAcquirer, !acquirer.displayName.isEmpty {
Text("获客员:\(acquirer.displayName)")
.font(.system(size: 13))
.foregroundStyle(OrderSourceColors.text666)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 4)
}
if viewModel.hasLoadedReferralOrdersOnce && viewModel.referralOrders.isEmpty {
Text("暂无可绑定带客单")
.font(.system(size: 14))
.foregroundStyle(OrderSourceColors.text999)
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
} else {
ForEach(Array(viewModel.referralOrders.enumerated()), id: \.element.id) { index, order in
let selected = viewModel.selectedReferralOrder?.id == order.id
ReferralOrderSelectCard(
order: order,
selected: selected
) {
viewModel.selectReferralOrder(order)
}
.onAppear {
guard index >= viewModel.referralOrders.count - 3 else { return }
guard viewModel.canLoadMore, !viewModel.isLoadingMore else { return }
Task { await viewModel.loadMoreReferralOrders(api: cooperationOrderAPI) }
}
}
if viewModel.isLoadingMore {
ProgressView()
.tint(OrderSourceColors.primary)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.refreshable {
await globalLoading.withOptionalLoading(false) {
await viewModel.refreshReferralOrders(api: cooperationOrderAPI)
}
}
}
private func confirmBind() async {
binding = true
defer { binding = false }
do {
try await viewModel.confirmBind(api: cooperationOrderAPI)
onBound()
dismiss()
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
/// Sheet
struct OrderNumberSheetItem: Identifiable {
let orderNumber: String
var id: String { orderNumber }
}