迁移合作订单完整能力,并统一 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

@ -0,0 +1,159 @@
//
// CooperationOrderAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
@MainActor
/// 便
protocol CooperationOrderServing {
///
func cooperativeSalers(storeId: Int?, page: Int, pageSize: Int) async throws -> CooperativeSalerListPayload
///
func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo
///
func saleUserAcceptBind(saleUserId: Int) async throws
/// 线
func saleUserReferralLeads(page: Int, pageSize: Int) async throws -> ListPayload<ReferralLead>
///
func saleUserAcquisitionOrders(keyword: String?, page: Int, pageSize: Int) async throws -> ListPayload<AcquisitionOrder>
///
func saleUserReferralOrders(saleUserId: Int, page: Int, pageSize: Int) async throws -> ReferralOrderListPayload
///
func saleUserBindReferralOrder(orderNumber: String, referralOrderNo: String) async throws
/// 退 status=3
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws
}
@MainActor
/// API线
final class CooperationOrderAPI: CooperationOrderServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func cooperativeSalers(storeId: Int? = nil, page: Int = 1, pageSize: Int = 10) async throws -> CooperativeSalerListPayload {
var query = [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let storeId, storeId > 0 {
query.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
}
return try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/cooperative-salers",
queryItems: query
)
)
}
///
func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo {
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) 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: "\(saleUserId)")]
)
)
}
/// 线
func saleUserReferralLeads(page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<ReferralLead> {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/referral-leads",
queryItems: [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
)
)
}
///
func saleUserAcquisitionOrders(keyword: String? = nil, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<AcquisitionOrder> {
var query = [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let keyword, !keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
query.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: query
)
)
}
///
func saleUserReferralOrders(saleUserId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ReferralOrderListPayload {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/sale-user/referral-orders",
queryItems: [
URLQueryItem(name: "sale_user_id", value: "\(saleUserId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
)
)
}
///
func saleUserBindReferralOrder(orderNumber: String, referralOrderNo: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/bind-referral-order",
queryItems: [
URLQueryItem(name: "order_number", value: orderNumber),
URLQueryItem(name: "referral_order_no", value: referralOrderNo)
]
)
)
}
///
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/referral-orders/\(id)/status",
body: ReferralOrderStatusUpdateRequest(status: status)
)
)
}
}

View File

@ -0,0 +1,57 @@
# 合作订单模块
## 职责
合作订单模块面向摄影师/门店角色,管理合作获客业务:
- **获客员线索**:查看合作获客员上报的潜在客户线索(手机号、备注、图片)
- **获客订单**:查看通过合作渠道产生的订单,支持关键词搜索
- **合作获客员**:查看已绑定获客员列表,扫码绑定新获客员
权限 URI`cooperation_order`(与 Android `CooperationOrderFeature.URI` 一致)。
## 核心流程
### 首页入口
1. 用户点击首页「合作订单」菜单(`cooperation_order`
2. `CooperationOrderAccessView` 校验 `PermissionContext.canAccess("cooperation_order")`
3. 无权限时 Toast 提示并返回;有权限进入 `CooperationOrderListView`
### 双 Tab 列表
| Tab | API | 说明 |
| --- | --- | --- |
| 获客员线索 | `GET .../sale-user/referral-leads` | 分页 10 条,下拉刷新/加载更多 |
| 获客订单 | `GET .../sale-user/acquisition-orders` | 支持 `keyword` 搜索 |
### 获客员绑定
1. 列表页右上角「合作获客员」→ `CooperationAcquirerView`
2. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}`
3. `BindAcquirerView` 加载邀请信息 → 确认 → `POST .../sale-user/accept-bind`
## 与订单模块关系
订单管理(`orderType` 11 摄影师跟拍 / 14 线下扫码)在订单卡片展示「订单来源」:
- Sheet 内选择获客员 → 带客单 → `POST .../bind-referral-order`
- 全页流程见 `ReferralOrderSelectView`
- 订单退款成功后,若已绑定带客单,调用 `POST .../referral-orders/{id}/status`status=3
## 主要文件
| 文件 | 说明 |
| --- | --- |
| `API/CooperationOrderAPI.swift` | 8 个 sale-user / cooperative 接口 |
| `Models/CooperationOrderModels.swift` | 实体与解码 |
| `Services/CooperationOrderFeature.swift` | 权限 URI 常量 |
| `ViewModels/*` | 列表、获客员、绑定 ViewModel |
| `Views/*` | SwiftUI 页面与卡片组件 |
## 依赖
- `@Environment(\.cooperationOrderAPI)` 注入网络层
- `@EnvironmentObject PermissionContext` 权限校验
- `@EnvironmentObject ToastCenter` 用户提示
- Kingfisher 加载线索图片

View File

@ -0,0 +1,517 @@
//
// CooperationOrderModels.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
extension String {
/// fallback
func cooperationNonEmptyOrDefault(_ fallback: String) -> String {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
}
///
struct ReferralOrderStatusUpdateRequest: Encodable {
let status: Int
}
///
struct CooperativeSalerListPayload: Decodable {
let total: Int
let list: [CooperativeSaler]
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case total
case list
case page
case pageSize = "page_size"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([CooperativeSaler].self, forKey: .list) ?? []
page = try container.decodeLossyInt(forKey: .page) ?? 0
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 0
}
}
///
struct CooperativeSaler: Decodable, Identifiable {
let saleUserId: Int
let bindingId: Int
let name: String
let salerName: String
let phone: String
let phoneMasked: String
let salerPhone: String
let shareCode: String
let storeId: Int
let bindTime: String
let boundAt: String
var id: Int { saleUserId }
var displayId: Int { saleUserId }
var displayName: String {
name.cooperationNonEmptyOrDefault(salerName)
}
var displayPhone: String {
salerPhone.cooperationNonEmptyOrDefault(phone)
}
var displayBindTime: String {
bindTime.cooperationNonEmptyOrDefault(boundAt)
}
enum CodingKeys: String, CodingKey {
case saleUserId = "sale_user_id"
case bindingId = "binding_id"
case name
case salerName = "saler_name"
case phone
case phoneMasked = "phone_masked"
case salerPhone = "saler_phone"
case shareCode = "share_code"
case storeId = "store_id"
case bindTime = "bind_time"
case boundAt = "bound_at"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
bindingId = try container.decodeLossyInt(forKey: .bindingId) ?? 0
name = try container.decodeLossyString(forKey: .name)
salerName = try container.decodeLossyString(forKey: .salerName)
phone = try container.decodeLossyString(forKey: .phone)
phoneMasked = try container.decodeLossyString(forKey: .phoneMasked)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
shareCode = try container.decodeLossyString(forKey: .shareCode)
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
bindTime = try container.decodeLossyString(forKey: .bindTime)
boundAt = try container.decodeLossyString(forKey: .boundAt)
}
}
///
struct SaleUserInviteInfo: Decodable {
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.decodeLossyInt(forKey: .saleUserId) ?? 0
isBound = try container.decodeLossyBool(forKey: .isBound) ?? false
photographerName = try container.decodeLossyString(forKey: .photographerName)
photographerPhone = try container.decodeLossyString(forKey: .photographerPhone)
salerName = try container.decodeLossyString(forKey: .salerName)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
shareCode = try container.decodeLossyString(forKey: .shareCode)
subtitle = try container.decodeLossyString(forKey: .subtitle)
title = try container.decodeLossyString(forKey: .title)
}
init(
saleUserId: Int,
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 ReferralLead: Decodable, Identifiable {
let id: Int
let leadId: Int
let userPhone: String
let userMobileMasked: String
let phone: String
let remark: String
let createdAt: String
let imageUrls: [String]
let images: [String]
var displayId: String {
let value = leadId > 0 ? leadId : id
return "\(value)"
}
var displayPhone: String {
userMobileMasked.cooperationNonEmptyOrDefault(userPhone.cooperationNonEmptyOrDefault(phone))
}
var displayImages: [String] {
imageUrls.isEmpty ? images : imageUrls
}
enum CodingKeys: String, CodingKey {
case id
case leadId = "lead_id"
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.decodeLossyInt(forKey: .id) ?? 0
leadId = try container.decodeLossyInt(forKey: .leadId) ?? 0
userPhone = try container.decodeLossyString(forKey: .userPhone)
userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked)
phone = try container.decodeLossyString(forKey: .phone)
remark = try container.decodeLossyString(forKey: .remark)
createdAt = try container.decodeLossyString(forKey: .createdAt)
imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
}
}
///
struct AcquisitionOrder: Decodable, Identifiable {
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 orderStatusName: String
let status: String
let saleUserName: String
let partnerName: String
let partnerRole: String
let remark: String
let imageUrls: [String]
var displayAmount: String {
orderAmount.cooperationNonEmptyOrDefault(amount.cooperationNonEmptyOrDefault(actualPayAmount))
}
var displayCustomerPhone: String {
userMobileMasked.cooperationNonEmptyOrDefault(customerPhone.cooperationNonEmptyOrDefault(phone))
}
var displayStatus: String {
orderStatusName.cooperationNonEmptyOrDefault(status)
}
var displayPartner: String {
if !saleUserName.isEmpty { return saleUserName }
if !partnerName.isEmpty { return partnerName }
return ""
}
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 orderStatusName = "order_status_name"
case status
case saleUserName = "sale_user_name"
case partnerName = "partner_name"
case partnerRole = "partner_role"
case remark
case imageUrls = "image_urls"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
projectName = try container.decodeLossyString(forKey: .projectName)
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
amount = try container.decodeLossyString(forKey: .amount)
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
createdAt = try container.decodeLossyString(forKey: .createdAt)
customerPhone = try container.decodeLossyString(forKey: .customerPhone)
userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked)
phone = try container.decodeLossyString(forKey: .phone)
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
status = try container.decodeLossyString(forKey: .status)
saleUserName = try container.decodeLossyString(forKey: .saleUserName)
partnerName = try container.decodeLossyString(forKey: .partnerName)
partnerRole = try container.decodeLossyString(forKey: .partnerRole)
remark = try container.decodeLossyString(forKey: .remark)
imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
}
}
///
struct ReferralOrderSaler: Decodable {
let saleUserId: Int
let salerName: String
let salerPhone: String
enum CodingKeys: String, CodingKey {
case saleUserId = "sale_user_id"
case salerName = "saler_name"
case salerPhone = "saler_phone"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
salerName = try container.decodeLossyString(forKey: .salerName)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
}
}
///
struct ReferralOrderListPayload: Decodable {
let total: Int
let list: [ReferralOrder]
let page: Int
let pageSize: Int
let saler: ReferralOrderSaler?
enum CodingKeys: String, CodingKey {
case total
case list
case page
case pageSize = "page_size"
case saler
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([ReferralOrder].self, forKey: .list) ?? []
page = try container.decodeLossyInt(forKey: .page) ?? 0
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 0
saler = try container.decodeIfPresent(ReferralOrderSaler.self, forKey: .saler)
}
}
///
struct ReferralOrder: Decodable, Identifiable {
let id: Int
let referralOrderNo: String
let orderNo: String
let userMobile: String
let remark: String
let createdAt: String
let status: Int
let statusLabel: String
let images: [String]
var displayReferralOrderNo: String {
referralOrderNo.cooperationNonEmptyOrDefault(orderNo)
}
enum CodingKeys: String, CodingKey {
case id
case referralOrderNo = "referral_order_no"
case orderNo = "order_no"
case userMobile = "user_mobile"
case remark
case createdAt = "created_at"
case status
case statusLabel = "status_label"
case images
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
orderNo = try container.decodeLossyString(forKey: .orderNo)
userMobile = try container.decodeLossyString(forKey: .userMobile)
remark = try container.decodeLossyString(forKey: .remark)
createdAt = try container.decodeLossyString(forKey: .createdAt)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
}
}
/// listv2 referral_order
struct BoundReferralOrder: Decodable, Equatable, Hashable {
let id: Int
let orderNo: String
let saleUserId: Int
let salerName: String
let salerPhone: String
let displayText: String
let userMobile: String
let remark: String
let bindOrderNumber: String
let sourceType: Int
let sourceTypeLabel: String
let status: Int
let statusLabel: String
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case orderNo = "order_no"
case saleUserId = "sale_user_id"
case salerName = "saler_name"
case salerPhone = "saler_phone"
case displayText = "display_text"
case userMobile = "user_mobile"
case remark
case bindOrderNumber = "bind_order_number"
case sourceType = "source_type"
case sourceTypeLabel = "source_type_label"
case status
case statusLabel = "status_label"
case createdAt = "created_at"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
orderNo = try container.decodeLossyString(forKey: .orderNo)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
salerName = try container.decodeLossyString(forKey: .salerName)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
displayText = try container.decodeLossyString(forKey: .displayText)
userMobile = try container.decodeLossyString(forKey: .userMobile)
remark = try container.decodeLossyString(forKey: .remark)
bindOrderNumber = try container.decodeLossyString(forKey: .bindOrderNumber)
sourceType = try container.decodeLossyInt(forKey: .sourceType) ?? 0
sourceTypeLabel = try container.decodeLossyString(forKey: .sourceTypeLabel)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
createdAt = try container.decodeLossyString(forKey: .createdAt)
}
}
/// Sheet
struct BindAcquirerSheetItem: Identifiable {
let saleUserId: Int
var id: Int { saleUserId }
}
///
enum CooperationOrderRoute: Hashable {
case acquirer
case bind(saleUserId: Int)
}
private extension KeyedDecodingContainer {
/// JSON
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
/// IntDoubleBool
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? 1 : 0
}
return nil
}
/// BoolInt
func decodeLossyBool(forKey key: Key) throws -> Bool? {
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value != 0
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes":
return true
case "0", "false", "no":
return false
default:
return nil
}
}
return nil
}
}

View File

@ -0,0 +1,18 @@
//
// CooperationOrderFeature.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
/// URI 访
enum CooperationOrderFeature {
static let uri = "cooperation_order"
///
static func canAccess(permissionContext: PermissionContext) -> Bool {
permissionContext.canAccess(uri)
}
}

View File

@ -0,0 +1,70 @@
//
// BindAcquirerViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
/// ViewModel
@MainActor
final class BindAcquirerViewModel: ObservableObject {
@Published var inviteInfo: SaleUserInviteInfo?
@Published var isLoading = false
@Published var isAlreadyBound = false
@Published var errorMessage: String?
private var saleUserId = 0
///
func load(api: CooperationOrderServing, saleUserId: Int) async {
guard saleUserId > 0 else {
errorMessage = "获客员信息无效"
return
}
if self.saleUserId == saleUserId && (inviteInfo != nil || isAlreadyBound) { return }
self.saleUserId = saleUserId
isAlreadyBound = false
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
var info = try await api.saleUserInviteInfo(saleUserId: saleUserId)
if info.saleUserId <= 0 {
info = SaleUserInviteInfo(
saleUserId: saleUserId,
isBound: info.isBound,
photographerName: info.photographerName,
photographerPhone: info.photographerPhone,
salerName: info.salerName,
salerPhone: info.salerPhone,
shareCode: info.shareCode,
subtitle: info.subtitle,
title: info.title
)
}
if info.isBound {
isAlreadyBound = true
}
inviteInfo = info
} catch {
handleBindError(error.localizedDescription.isEmpty ? "获取邀请信息失败" : error.localizedDescription)
}
}
///
func acceptBind(api: CooperationOrderServing) async throws {
guard saleUserId > 0, !isAlreadyBound else { return }
try await api.saleUserAcceptBind(saleUserId: saleUserId)
}
///
func handleBindError(_ message: String) {
errorMessage = message
if message.contains("已绑定") {
isAlreadyBound = true
}
}
}

View File

@ -0,0 +1,30 @@
//
// CooperationAcquirerViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
/// ViewModel
@MainActor
final class CooperationAcquirerViewModel: ObservableObject {
@Published var acquirers: [CooperativeSaler] = []
@Published private(set) var hasLoadedOnce = false
///
func reload(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: 20)
acquirers = data.list
} catch {
if !hasLoadedOnce {
acquirers = []
}
}
hasLoadedOnce = true
}
}

View File

@ -0,0 +1,130 @@
//
// CooperationOrderListViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
/// ViewModel线 Tab
@MainActor
final class CooperationOrderListViewModel: ObservableObject {
@Published var selectedTab = 0
@Published var searchText = ""
@Published var leads: [ReferralLead] = []
@Published var orders: [AcquisitionOrder] = []
@Published var isLoadingMore = false
@Published var canLoadMore = false
@Published private(set) var hasLoadedLeadsOnce = false
@Published private(set) var hasLoadedOrdersOnce = false
private var leadsPage = 1
private var ordersPage = 1
private var leadsTotal = 0
private var ordersTotal = 0
private var isReloading = false
private let pageSize = 10
/// Tab
func onTabSelected(_ index: Int) {
guard selectedTab != index else { return }
selectedTab = index
}
/// Tab
func refreshCurrentTab(api: CooperationOrderServing) async {
if selectedTab == 0 {
await refreshLeads(api: api)
} else {
await refreshOrders(api: api)
}
}
/// 线
func refreshLeads(api: CooperationOrderServing) async {
guard !isReloading else { return }
isReloading = true
defer { isReloading = false }
leadsPage = 1
isLoadingMore = false
await loadLeads(api: api)
}
///
func refreshOrders(api: CooperationOrderServing) async {
guard !isReloading else { return }
isReloading = true
defer { isReloading = false }
ordersPage = 1
isLoadingMore = false
await loadOrders(api: api)
}
/// Tab
func loadMore(api: CooperationOrderServing) async {
guard !isReloading, !isLoadingMore, canLoadMore else { return }
isLoadingMore = true
if selectedTab == 0 {
leadsPage += 1
await loadLeads(api: api)
} else {
ordersPage += 1
await loadOrders(api: api)
}
}
///
func searchOrders(api: CooperationOrderServing) async {
guard selectedTab == 1 else { return }
await refreshOrders(api: api)
}
private func loadLeads(api: CooperationOrderServing) async {
do {
let data = try await api.saleUserReferralLeads(page: leadsPage, pageSize: pageSize)
leadsTotal = data.total
if leadsPage == 1 {
leads = data.list
} else {
leads.append(contentsOf: data.list)
}
canLoadMore = leads.count < leadsTotal
} catch {
if leadsPage == 1, !hasLoadedLeadsOnce {
leads = []
}
}
if leadsPage == 1 {
hasLoadedLeadsOnce = true
}
isLoadingMore = false
}
private func loadOrders(api: CooperationOrderServing) async {
do {
let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let data = try await api.saleUserAcquisitionOrders(
keyword: keyword.isEmpty ? nil : keyword,
page: ordersPage,
pageSize: pageSize
)
ordersTotal = data.total
if ordersPage == 1 {
orders = data.list
} else {
orders.append(contentsOf: data.list)
}
canLoadMore = orders.count < ordersTotal
} catch {
if ordersPage == 1, !hasLoadedOrdersOnce {
orders = []
}
}
if ordersPage == 1 {
hasLoadedOrdersOnce = true
}
isLoadingMore = false
}
}

View File

@ -0,0 +1,141 @@
//
// BindAcquirerView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct BindAcquirerView: View {
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = BindAcquirerViewModel()
let saleUserId: Int
let onSuccess: () -> Void
@State private var binding = false
var body: some View {
VStack(spacing: 12) {
if viewModel.isLoading && viewModel.inviteInfo == nil && !viewModel.isAlreadyBound {
Spacer()
ProgressView()
Spacer()
} else if let info = viewModel.inviteInfo {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text(info.title.cooperationNonEmptyOrDefault("绑定获客员"))
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
if !info.subtitle.isEmpty {
Text(info.subtitle)
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.9))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 10))
infoCard(title: "获客员", value: info.salerName)
infoCard(title: "手机号", value: info.salerPhone)
}
.padding(16)
Spacer()
Button {
Task { await acceptBind() }
} label: {
Text(binding ? "提交中..." : (viewModel.isAlreadyBound ? "已绑定获客员" : "接受邀请"))
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(
viewModel.isAlreadyBound ? AppDesign.textSecondary : AppDesign.primary,
in: RoundedRectangle(cornerRadius: 8)
)
}
.buttonStyle(.plain)
.disabled(binding || viewModel.isAlreadyBound)
.padding(.horizontal, 16)
.padding(.bottom, 12)
} else if viewModel.isAlreadyBound {
Spacer()
Text("已绑定获客员")
.font(.system(size: 14))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Button {} label: {
Text("已绑定获客员")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(AppDesign.textSecondary, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(true)
.padding(.horizontal, 16)
.padding(.bottom, 12)
} else if let error = viewModel.errorMessage {
Spacer()
Text(error)
.font(.system(size: 14))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
.padding()
Spacer()
}
}
.navigationTitle(viewModel.inviteInfo?.title.cooperationNonEmptyOrDefault("接受邀请") ?? "接受邀请")
.navigationBarTitleDisplayMode(.inline)
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.task {
await viewModel.load(api: cooperationOrderAPI, saleUserId: saleUserId)
if viewModel.errorMessage != nil && viewModel.inviteInfo == nil && !viewModel.isAlreadyBound {
if let message = viewModel.errorMessage {
toastCenter.show(message)
}
dismiss()
}
}
}
private func infoCard(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 13))
.foregroundStyle(AppDesign.textSecondary)
Text(value.isEmpty ? "--" : value)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
}
private func acceptBind() async {
binding = true
defer { binding = false }
do {
try await viewModel.acceptBind(api: cooperationOrderAPI)
toastCenter.show("绑定成功")
onSuccess()
dismiss()
} catch {
viewModel.handleBindError(error.localizedDescription.isEmpty ? "绑定失败" : error.localizedDescription)
if let message = viewModel.errorMessage {
toastCenter.show(message)
}
if !viewModel.isAlreadyBound {
dismiss()
}
}
}
}

View File

@ -0,0 +1,89 @@
//
// CooperationAcquirerView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct CooperationAcquirerView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@Environment(\.globalLoading) private var globalLoading
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = CooperationAcquirerViewModel()
@State private var showScanner = false
let onBind: (Int) -> Void
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
if viewModel.hasLoadedOnce && viewModel.acquirers.isEmpty {
Text("暂无合作获客员")
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text999)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
} else {
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
CooperativeSalerCard(acquirer: acquirer) { phone in
dialPhone(phone)
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.navigationTitle("合作获客员")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("扫码绑定") {
showScanner = true
}
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.primary)
}
}
.background(CooperationOrderColors.pageBg.ignoresSafeArea())
.refreshable {
await reload(showLoading: false)
}
.fullScreenCover(isPresented: $showScanner) {
OrderScannerPage(
onClose: { showScanner = false },
onSuccess: { raw in
showScanner = false
if let saleUserId = SaleUserQrParser.parseSaleUserId(raw) {
onBind(saleUserId)
} else {
toastCenter.show("请扫描正确的获客员二维码")
}
},
onFailure: { error in
showScanner = false
toastCenter.show(error.localizedDescription)
}
)
}
.task {
await reload(showLoading: true)
}
}
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.reload(api: cooperationOrderAPI, storeId: accountContext.currentStore?.id)
}
}
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
}

View File

@ -0,0 +1,29 @@
//
// CooperationOrderAccessView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
///
struct CooperationOrderAccessView: View {
@EnvironmentObject private var permissionContext: PermissionContext
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss
var body: some View {
Group {
if CooperationOrderFeature.canAccess(permissionContext: permissionContext) {
CooperationOrderListView()
} else {
Color.clear
.onAppear {
toastCenter.show("暂无合作订单权限")
dismiss()
}
}
}
}
}

View File

@ -0,0 +1,379 @@
//
// CooperationOrderComponents.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
import Kingfisher
enum CooperationOrderColors {
static let primary = Color(hex: 0x0073FF)
static let text333 = Color(hex: 0x333333)
static let text666 = Color(hex: 0x666666)
static let text999 = Color(hex: 0x999999)
static let searchPlaceholder = Color(hex: 0xB6BECA)
static let searchBg = Color(hex: 0xF4F4F4)
static let imageEmptyBg = Color(hex: 0xF7F8FA)
static let imagePlaceholderBg = Color(hex: 0xF4F4F4)
static let pageBg = Color(hex: 0xF5F7FA)
}
struct CooperationCard<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
content()
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.shadow(color: Color.black.opacity(0.06), radius: 1, x: 0, y: 1)
}
}
struct CooperationListInfoRow: View {
let label: String
let value: String
var body: some View {
HStack(alignment: .center, spacing: 0) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(value)
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text333)
.lineLimit(1)
.truncationMode(.tail)
.padding(.leading, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct CooperationTypeTag: View {
let text: String
var body: some View {
Text(text)
.font(.system(size: 12))
.foregroundStyle(CooperationOrderColors.primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(CooperationOrderColors.primary.opacity(0.09), in: RoundedRectangle(cornerRadius: 4))
}
}
struct CooperationOrderTabs: View {
let selectedTab: Int
let onTabSelected: (Int) -> Void
private let tabs = ["获客员线索", "获客订单"]
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
ForEach(Array(tabs.enumerated()), id: \.offset) { index, title in
Button {
onTabSelected(index)
} label: {
Text(title)
.font(.system(size: 15, weight: selectedTab == index ? .bold : .regular))
.foregroundStyle(selectedTab == index ? CooperationOrderColors.primary : CooperationOrderColors.text666)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.plain)
}
}
GeometryReader { geo in
let tabWidth = geo.size.width / CGFloat(tabs.count)
RoundedRectangle(cornerRadius: 1.5)
.fill(CooperationOrderColors.primary)
.frame(width: max(tabWidth - 68, 0), height: 3)
.offset(x: tabWidth * CGFloat(selectedTab) + 34, y: 0)
}
.frame(height: 3)
}
.background(.white)
}
}
struct CooperationOrderSearchBar: View {
@Binding var searchText: String
let onSearch: () -> Void
var body: some View {
HStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 18))
.foregroundStyle(CooperationOrderColors.text999)
TextField(
"",
text: $searchText,
prompt: Text("名称搜索").foregroundColor(CooperationOrderColors.searchPlaceholder)
)
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text333)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.submitLabel(.search)
.onSubmit(onSearch)
}
.padding(.horizontal, 12)
.frame(height: 38)
.background(CooperationOrderColors.searchBg, in: RoundedRectangle(cornerRadius: 6))
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(.white)
}
}
struct CooperationImagePreviewItem: Identifiable {
let urls: [String]
let index: Int
var id: String { "\(index)-\(urls.joined())" }
}
struct ReferralLeadCard: View {
let lead: ReferralLead
@State private var previewIndex: Int?
@State private var previewImages: [String] = []
var body: some View {
CooperationCard {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 0) {
Text("用户手机号")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(lead.displayPhone.isEmpty ? "" : lead.displayPhone)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(CooperationOrderColors.text333)
.padding(.leading, 14)
Spacer(minLength: 8)
Text(lead.createdAt)
.font(.system(size: 11))
.foregroundStyle(CooperationOrderColors.text999)
}
Text("备注")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(lead.remark.isEmpty ? "暂无备注" : lead.remark)
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text333)
.lineSpacing(7)
Text("图片")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
ReferralLeadImageGrid(images: lead.displayImages) { images, index in
previewImages = images
previewIndex = index
}
}
}
.sheet(item: previewBinding) { item in
SimpleImagePreviewSheet(
imageUrls: item.urls,
startIndex: item.index,
onDismiss: { previewIndex = nil }
)
}
}
private var previewBinding: Binding<CooperationImagePreviewItem?> {
Binding(
get: {
guard let previewIndex else { return nil }
return CooperationImagePreviewItem(urls: previewImages, index: previewIndex)
},
set: { newValue in
if newValue == nil { previewIndex = nil }
}
)
}
}
struct ReferralLeadImageGrid: View {
let images: [String]
var onImageTap: ([String], Int) -> Void = { _, _ in }
var body: some View {
if images.isEmpty {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(CooperationOrderColors.imageEmptyBg)
.frame(maxWidth: .infinity)
.frame(height: 72)
Text("暂无图片")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
}
} else {
HStack(spacing: 8) {
ForEach(0..<3, id: \.self) { index in
if index < images.count, let url = URL(string: images[index]) {
KFImage(url)
.placeholder { CooperationOrderColors.imagePlaceholderBg }
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
.onTapGesture {
onImageTap(images, index)
}
} else {
Color.clear
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
}
}
}
}
}
}
struct AcquisitionOrderCard: View {
let order: AcquisitionOrder
var body: some View {
CooperationCard {
VStack(alignment: .leading, spacing: 10) {
Text(order.projectName.isEmpty ? "" : order.projectName)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(CooperationOrderColors.text333)
.lineLimit(1)
.truncationMode(.tail)
HStack(alignment: .center, spacing: 8) {
if !order.orderTypeLabel.isEmpty {
CooperationTypeTag(text: order.orderTypeLabel)
}
Text("订单号 \(order.orderNumber)")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text666)
.lineLimit(1)
.truncationMode(.tail)
}
CooperationListInfoRow(
label: "合作人员",
value: order.displayPartner.isEmpty ? "暂未绑定" : order.displayPartner
)
CooperationListInfoRow(
label: "下单时间",
value: order.createdAt.isEmpty ? "" : order.createdAt
)
HStack(alignment: .center, spacing: 0) {
Text("客户 \(order.displayCustomerPhone.isEmpty ? "" : order.displayCustomerPhone)")
.font(.system(size: 12))
.foregroundStyle(CooperationOrderColors.text999)
.frame(maxWidth: .infinity, alignment: .leading)
Text("¥\(order.displayAmount.isEmpty ? "" : order.displayAmount)")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(CooperationOrderColors.primary)
}
}
}
}
}
struct CooperativeSalerCard: View {
let acquirer: CooperativeSaler
let onDial: (String) -> Void
var body: some View {
HStack(alignment: .center, spacing: 0) {
Image(systemName: "person.fill")
.font(.system(size: 20))
.foregroundStyle(CooperationOrderColors.primary)
.padding(10)
.background(CooperationOrderColors.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 8) {
AcquirerInfoRow(
label: "获客员名称",
value: acquirer.displayName.isEmpty ? "" : acquirer.displayName,
emphasize: true
)
AcquirerPhoneInfoRow(phone: acquirer.displayPhone, onDial: onDial)
AcquirerInfoRow(
label: "绑定时间",
value: acquirer.displayBindTime.isEmpty ? "" : acquirer.displayBindTime
)
}
.padding(.leading, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 14)
.padding(.vertical, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 10))
.shadow(color: Color.black.opacity(0.06), radius: 1, x: 0, y: 1)
}
}
struct AcquirerInfoRow: View {
let label: String
let value: String
var emphasize: Bool = false
var body: some View {
HStack(alignment: .center, spacing: 0) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(value)
.font(.system(size: emphasize ? 15 : 13, weight: emphasize ? .bold : .regular))
.foregroundStyle(emphasize ? CooperationOrderColors.text333 : CooperationOrderColors.text666)
.padding(.leading, 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AcquirerPhoneInfoRow: View {
let phone: String
let onDial: (String) -> Void
var body: some View {
HStack(alignment: .center, spacing: 0) {
Text("手机号")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(maskAcquirerPhone(phone))
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text666)
.padding(.leading, 12)
Spacer(minLength: 8)
if !phone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button {
onDial(phone)
} label: {
Image(systemName: "phone.fill")
.font(.system(size: 15))
.foregroundStyle(CooperationOrderColors.primary)
.frame(width: 30, height: 30)
.background(Color(hex: 0xEFF6FF), in: Circle())
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
func maskAcquirerPhone(_ phone: String) -> String {
let digits = phone.filter(\.isNumber)
guard digits.count >= 7 else { return phone.isEmpty ? "" : phone }
return "\(digits.prefix(3))****\(digits.suffix(4))"
}

View File

@ -0,0 +1,123 @@
//
// CooperationOrderListView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// 线 Tab
struct CooperationOrderListView: View {
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@Environment(\.globalLoading) private var globalLoading
@StateObject private var viewModel = CooperationOrderListViewModel()
@State private var route: CooperationOrderRoute?
var body: some View {
VStack(spacing: 0) {
CooperationOrderTabs(
selectedTab: viewModel.selectedTab,
onTabSelected: { viewModel.onTabSelected($0) }
)
if viewModel.selectedTab == 1 {
CooperationOrderSearchBar(searchText: $viewModel.searchText) {
Task { await searchOrders(showLoading: true) }
}
}
ScrollView {
LazyVStack(spacing: 12) {
if viewModel.selectedTab == 0 {
if viewModel.hasLoadedLeadsOnce && viewModel.leads.isEmpty {
emptyState("暂无获客员线索")
} else {
ForEach(Array(viewModel.leads.enumerated()), id: \.element.id) { index, lead in
ReferralLeadCard(lead: lead)
.onAppear {
guard index >= viewModel.leads.count - 3 else { return }
Task { await viewModel.loadMore(api: cooperationOrderAPI) }
}
}
}
} else {
if viewModel.hasLoadedOrdersOnce && viewModel.orders.isEmpty {
emptyState("暂无相关合作订单")
} else {
ForEach(Array(viewModel.orders.enumerated()), id: \.element.id) { index, order in
AcquisitionOrderCard(order: order)
.onAppear {
guard index >= viewModel.orders.count - 3 else { return }
Task { await viewModel.loadMore(api: cooperationOrderAPI) }
}
}
}
}
if viewModel.isLoadingMore {
ProgressView()
.tint(CooperationOrderColors.primary)
.frame(width: 24, height: 24)
.padding(16)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.refreshable {
await reloadCurrentTab(showLoading: false)
}
}
.navigationTitle("合作订单")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("合作获客员") {
route = .acquirer
}
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.primary)
}
}
.background(CooperationOrderColors.pageBg.ignoresSafeArea())
.appNavigationDestination(item: $route) { destination in
switch destination {
case .acquirer:
CooperationAcquirerView(onBind: { saleUserId in
route = .bind(saleUserId: saleUserId)
})
case let .bind(saleUserId):
BindAcquirerView(saleUserId: saleUserId, onSuccess: {
route = .acquirer
})
}
}
.task {
await reloadCurrentTab(showLoading: true)
}
.onChange(of: viewModel.selectedTab) { _ in
Task { await reloadCurrentTab(showLoading: true) }
}
}
private func reloadCurrentTab(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.refreshCurrentTab(api: cooperationOrderAPI)
}
}
private func searchOrders(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.searchOrders(api: cooperationOrderAPI)
}
}
private func emptyState(_ text: String) -> some View {
Text(text)
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text999)
.frame(maxWidth: .infinity)
.padding(.vertical, 48)
}
}