迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。
新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -61,6 +61,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
let photoTravel: OrderPhotoTravel?
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
let canBindReferral: Int
|
||||
let showOrderSource: Int
|
||||
let referralOrderNo: String
|
||||
let referralSalerName: String
|
||||
let referralOrder: BoundReferralOrder?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case photogUid = "photog_uid"
|
||||
@ -89,6 +94,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
case photoTravel = "photo_travel"
|
||||
case isNeedEdit
|
||||
case isRefined = "is_refined"
|
||||
case canBindReferral = "can_bind_referral"
|
||||
case showOrderSource = "show_order_source"
|
||||
case referralOrderNo = "referral_order_no"
|
||||
case referralSalerName = "referral_saler_name"
|
||||
case referralOrder = "referral_order"
|
||||
}
|
||||
|
||||
/// 宽松解码订单字段,兼容后端数字和字符串混用。
|
||||
@ -120,6 +130,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
|
||||
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
|
||||
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
|
||||
canBindReferral = try container.decodeLossyInt(forKey: .canBindReferral) ?? 0
|
||||
showOrderSource = try container.decodeLossyInt(forKey: .showOrderSource) ?? 0
|
||||
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
|
||||
referralSalerName = try container.decodeLossyString(forKey: .referralSalerName)
|
||||
referralOrder = try container.decodeIfPresent(BoundReferralOrder.self, forKey: .referralOrder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
106
suixinkan/Features/Orders/Models/OrderSourceModels.swift
Normal file
106
suixinkan/Features/Orders/Models/OrderSourceModels.swift
Normal file
@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
struct OrderSourcePerson: Identifiable, Hashable {
|
||||
let key: String
|
||||
let name: String
|
||||
let phone: String
|
||||
|
||||
var id: String { key }
|
||||
}
|
||||
|
||||
struct OrderSourceLead: Identifiable, Hashable {
|
||||
let key: String
|
||||
let userPhone: String
|
||||
let remark: String
|
||||
let images: [String]
|
||||
|
||||
var id: String { key }
|
||||
}
|
||||
|
||||
struct OrderSourceSelection: Hashable {
|
||||
let person: OrderSourcePerson
|
||||
let lead: OrderSourceLead
|
||||
}
|
||||
|
||||
extension CooperativeSaler {
|
||||
func toOrderSourcePerson() -> OrderSourcePerson {
|
||||
OrderSourcePerson(
|
||||
key: "\(displayId)",
|
||||
name: displayName,
|
||||
phone: displayPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ReferralOrder {
|
||||
func toOrderSourceLead() -> OrderSourceLead {
|
||||
let remarkText: String
|
||||
if !remark.isEmpty {
|
||||
remarkText = remark
|
||||
} else if !statusLabel.isEmpty {
|
||||
remarkText = statusLabel
|
||||
} else {
|
||||
remarkText = "带客单 \(displayReferralOrderNo)"
|
||||
}
|
||||
return OrderSourceLead(
|
||||
key: displayReferralOrderNo,
|
||||
userPhone: userMobile,
|
||||
remark: remarkText,
|
||||
images: images
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension BoundReferralOrder {
|
||||
func toOrderSourceSelection(fallbackSalerName: String = "") -> OrderSourceSelection? {
|
||||
let referralNo = orderNo.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !referralNo.isEmpty else { return nil }
|
||||
let personName = salerName.isEmpty
|
||||
? (fallbackSalerName.isEmpty ? "—" : fallbackSalerName)
|
||||
: salerName
|
||||
let remarkText: String
|
||||
if !remark.isEmpty {
|
||||
remarkText = remark
|
||||
} else if !displayText.isEmpty {
|
||||
remarkText = displayText
|
||||
} else {
|
||||
remarkText = "带客单号 \(referralNo)"
|
||||
}
|
||||
return OrderSourceSelection(
|
||||
person: OrderSourcePerson(
|
||||
key: saleUserId > 0 ? "\(saleUserId)" : "",
|
||||
name: personName,
|
||||
phone: salerPhone
|
||||
),
|
||||
lead: OrderSourceLead(
|
||||
key: referralNo,
|
||||
userPhone: userMobile,
|
||||
remark: remarkText,
|
||||
images: []
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension OrderEntity {
|
||||
func toBoundOrderSourceSelection() -> OrderSourceSelection? {
|
||||
if let referralOrder,
|
||||
let selection = referralOrder.toOrderSourceSelection(fallbackSalerName: referralSalerName) {
|
||||
return selection
|
||||
}
|
||||
guard !referralOrderNo.isEmpty else { return nil }
|
||||
return OrderSourceSelection(
|
||||
person: OrderSourcePerson(
|
||||
key: "",
|
||||
name: referralSalerName.isEmpty ? "—" : referralSalerName,
|
||||
phone: ""
|
||||
),
|
||||
lead: OrderSourceLead(
|
||||
key: referralOrderNo,
|
||||
userPhone: phone,
|
||||
remark: "带客单号 \(referralOrderNo)",
|
||||
images: []
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传和尾片入口闭环。
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传、尾片入口,以及 orderType 11/14 的订单来源(带客单绑定)闭环。
|
||||
|
||||
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。
|
||||
|
||||
@ -24,7 +24,7 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
|
||||
|
||||
## 数据流程
|
||||
|
||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentRole` 读取角色 ID。景区管理员角色 `roleId == 53` 使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentAppRole` 读取当前角色。景区管理员角色(`AppRoleCode.scenicAdmin`)使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
||||
|
||||
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。
|
||||
|
||||
@ -42,6 +42,8 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
|
||||
|
||||
视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。
|
||||
|
||||
订单来源(带客单绑定)仅对 `orderType == 11`(摄影师跟拍)或 `14`(线下扫码)展示,景区管理员只读不可操作。订单卡片嵌入 `OrderSourcePicker` Sheet:选择合作获客员 → 加载其可绑定带客单 → 确认绑定。全页流程使用 `ReferralOrderSelectView`。绑定接口为 `POST .../sale-user/bind-referral-order`;普通订单退款成功后若存在 `referralOrder.id`,异步调用 `POST .../sale-user/referral-orders/{id}/status`(status=3)。获客员与带客单数据由 `CooperationOrderAPI` 提供。
|
||||
|
||||
## 路由边界
|
||||
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。
|
||||
|
||||
@ -31,6 +31,16 @@ final class OrdersViewModel: ObservableObject {
|
||||
@Published private(set) var writeOffPage = 1
|
||||
@Published private(set) var writeOffHasMore = false
|
||||
|
||||
@Published var orderSourcePeople: [OrderSourcePerson] = []
|
||||
@Published var orderSourceLeads: [String: [OrderSourceLead]] = [:]
|
||||
@Published var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
||||
@Published var loadingOrderSourceLeadsFor: String?
|
||||
|
||||
private var loadingOrderSourcePeople = false
|
||||
|
||||
static let orderTypePhotographerFollow = 11
|
||||
static let orderTypePhotographerFollowOfflineScan = 14
|
||||
|
||||
/// 返回去除首尾空白后的手机号搜索值。
|
||||
var normalizedPhone: String? {
|
||||
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@ -38,17 +48,17 @@ final class OrdersViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
/// 根据当前子入口刷新对应列表。
|
||||
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, appRole: AppRoleCode?, showLoading: Bool = true) async throws {
|
||||
switch selectedEntry {
|
||||
case .storeOrders:
|
||||
try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading)
|
||||
try await reloadStoreOrders(api: api, scenicId: scenicId, appRole: appRole, showLoading: showLoading)
|
||||
case .verificationOrders:
|
||||
try await reloadWriteOffOrders(api: api, scenicId: scenicId, storeId: storeId, showLoading: showLoading)
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新订单管理列表,缺少景区时清空旧数据。
|
||||
func reloadStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
func reloadStoreOrders(api: OrderServing, scenicId: Int?, appRole: AppRoleCode?, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
storeOrders = []
|
||||
storeTotal = 0
|
||||
@ -69,16 +79,17 @@ final class OrdersViewModel: ObservableObject {
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
isScenicAdmin: appRole?.isScenicAdmin == true
|
||||
)
|
||||
storeOrders = result.list
|
||||
storeTotal = result.total
|
||||
storePage = 1
|
||||
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
|
||||
syncOrderSourceSelections(from: result.list)
|
||||
}
|
||||
|
||||
/// 加载订单管理下一页。
|
||||
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, appRole: AppRoleCode?) async throws {
|
||||
guard !loadingMore, storeHasMore, let scenicId else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
@ -93,7 +104,7 @@ final class OrdersViewModel: ObservableObject {
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
isScenicAdmin: appRole?.isScenicAdmin == true
|
||||
)
|
||||
storeOrders.append(contentsOf: result.list)
|
||||
storeTotal = result.total
|
||||
@ -154,6 +165,81 @@ final class OrdersViewModel: ObservableObject {
|
||||
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
|
||||
}
|
||||
|
||||
/// 判断订单卡片是否展示订单来源入口。
|
||||
func shouldShowOrderSource(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
|
||||
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
|
||||
return order.orderType == Self.orderTypePhotographerFollow
|
||||
|| order.orderType == Self.orderTypePhotographerFollowOfflineScan
|
||||
}
|
||||
|
||||
/// 景区管理员只读模式,不可操作订单来源。
|
||||
static func isScenicAdminReadOnly(appRole: AppRoleCode?) -> Bool {
|
||||
appRole?.isScenicAdmin == true
|
||||
}
|
||||
|
||||
/// 获取订单已选中的订单来源。
|
||||
func getOrderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
|
||||
orderSourceSelections[order.orderNumber] ?? order.toBoundOrderSourceSelection()
|
||||
}
|
||||
|
||||
/// 从列表数据同步已绑定订单来源。
|
||||
func syncOrderSourceSelections(from orders: [OrderEntity]) {
|
||||
var updated = orderSourceSelections
|
||||
for order in orders {
|
||||
if let selection = order.toBoundOrderSourceSelection() {
|
||||
updated[order.orderNumber] = selection
|
||||
}
|
||||
}
|
||||
orderSourceSelections = updated
|
||||
}
|
||||
|
||||
/// 打开订单来源 Sheet 前预加载获客员列表。
|
||||
func prepareOrderSourceSheet(api: CooperationOrderServing, storeId: Int?) async {
|
||||
orderSourceLeads = [:]
|
||||
await loadOrderSourcePeople(api: api, storeId: storeId)
|
||||
}
|
||||
|
||||
/// 加载合作获客员列表供订单来源选择。
|
||||
func loadOrderSourcePeople(api: CooperationOrderServing, storeId: Int?) async {
|
||||
if loadingOrderSourcePeople { return }
|
||||
loadingOrderSourcePeople = true
|
||||
defer { loadingOrderSourcePeople = false }
|
||||
do {
|
||||
let storeParam = storeId.flatMap { $0 > 0 ? $0 : nil }
|
||||
let data = try await api.cooperativeSalers(storeId: storeParam, page: 1, pageSize: 50)
|
||||
orderSourcePeople = data.list.map { $0.toOrderSourcePerson() }
|
||||
} catch {
|
||||
orderSourcePeople = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载指定获客员的可绑定带客单。
|
||||
func loadOrderSourceLeads(api: CooperationOrderServing, person: OrderSourcePerson) async {
|
||||
if loadingOrderSourceLeadsFor == person.key { return }
|
||||
loadingOrderSourceLeadsFor = person.key
|
||||
defer { loadingOrderSourceLeadsFor = nil }
|
||||
guard let saleUserId = Int(person.key) else { return }
|
||||
do {
|
||||
let data = try await api.saleUserReferralOrders(saleUserId: saleUserId, page: 1, pageSize: 50)
|
||||
orderSourceLeads[person.key] = data.list.map { $0.toOrderSourceLead() }
|
||||
} catch {
|
||||
orderSourceLeads[person.key] = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定订单来源带客单。
|
||||
func bindOrderSource(
|
||||
api: CooperationOrderServing,
|
||||
orderNumber: String,
|
||||
selection: OrderSourceSelection
|
||||
) async throws {
|
||||
try await api.saleUserBindReferralOrder(
|
||||
orderNumber: orderNumber,
|
||||
referralOrderNo: selection.lead.key
|
||||
)
|
||||
orderSourceSelections[orderNumber] = selection
|
||||
}
|
||||
|
||||
/// 解析扫码结果并返回当前核销列表中的匹配订单。
|
||||
func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? {
|
||||
guard let orderNumber = OrderNumberParser.parse(rawCode) else {
|
||||
|
||||
@ -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)
|
||||
|
||||
365
suixinkan/Features/Orders/Views/OrderSourceComponents.swift
Normal file
365
suixinkan/Features/Orders/Views/OrderSourceComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
227
suixinkan/Features/Orders/Views/OrderSourcePicker.swift
Normal file
227
suixinkan/Features/Orders/Views/OrderSourcePicker.swift
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
312
suixinkan/Features/Orders/Views/ReferralOrderSelectView.swift
Normal file
312
suixinkan/Features/Orders/Views/ReferralOrderSelectView.swift
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user