Files
suixinkan_ios_new/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift
汉秋 49e997ddba 修复合作获客扫码绑定崩溃与 Toast 层级,并接入分成比例修改。
将扫码页提升到列表层弹出并补齐 AppDelegate.window,避免扫码时 UIKit 取主窗口崩溃;Toast 改用独立 UIWindow 显示在 sheet 之上;合作获客员支持短信验证修改分成比例,同时优化冷启动根视图创建时机并补充相关测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 20:37:24 +08:00

584 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
CooperationListInfoRow(
label: "获客员",
value: lead.displaySalerName.isEmpty ? "" : lead.displaySalerName
)
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
imageCell(at: index)
}
}
}
}
/// 1:1 Kingfisher
@ViewBuilder
private func imageCell(at index: Int) -> some View {
Group {
if index < images.count, let url = URL(string: images[index]) {
RoundedRectangle(cornerRadius: 6)
.fill(CooperationOrderColors.imagePlaceholderBg)
.overlay {
KFImage(url)
.placeholder { CooperationOrderColors.imagePlaceholderBg }
.resizable()
.scaledToFill()
}
} else {
Color.clear
}
}
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
.onTapGesture {
guard index < images.count else { return }
onImageTap(images, index)
}
}
}
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
let onEditRemark: () -> Void
let onEditCommission: () -> Void
var body: some View {
ZStack(alignment: .topTrailing) {
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
)
AcquirerCommissionInfoRow(
commissionRate: acquirer.displayCommissionRate.isEmpty ? "" : acquirer.displayCommissionRate,
onEdit: onEditCommission
)
}
.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)
Button("修改备注") {
onEditRemark()
}
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.primary)
.padding(.top, 16)
.padding(.trailing, 14)
}
}
}
struct AcquirerCommissionInfoRow: View {
let commissionRate: String
let onEdit: () -> Void
var body: some View {
HStack(alignment: .center, spacing: 0) {
Text("分成比例")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text999)
Text(commissionRate)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(CooperationOrderColors.text666)
.padding(.leading, 12)
Spacer(minLength: 8)
Button("修改比例") {
onEdit()
}
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.primary)
.buttonStyle(.plain)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct BindCommissionRateInputRow: View {
@Binding var value: String
var enabled: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 7) {
Text("分账比例")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text666)
HStack(spacing: 8) {
TextField("请输入0到100", text: $value)
.keyboardType(.numberPad)
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text333)
.disabled(!enabled)
Text("%")
.font(.system(size: 14))
.foregroundStyle(CooperationOrderColors.text666)
}
.padding(.horizontal, 12)
.padding(.vertical, 13)
.background(Color(hex: 0xF7F8FA), in: RoundedRectangle(cornerRadius: 8))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct CommissionRateEditSheet: View {
let acquirer: CooperativeSaler
@Binding var commissionRate: String
@Binding var smsCode: String
let smsCountdown: Int
let sending: Bool
let submitting: Bool
let onSendSmsCode: () -> Void
let onConfirm: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Capsule()
.fill(Color.black.opacity(0.16))
.frame(width: 36, height: 4)
.frame(maxWidth: .infinity)
.padding(.top, 8)
Text("修改分成比例")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(CooperationOrderColors.text333)
Text("修改分成比例需验证手机号")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.primary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(CooperationOrderColors.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 8) {
Text("分成比例")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text666)
HStack(spacing: 8) {
TextField("请输入0到100", text: $commissionRate)
.keyboardType(.numberPad)
.font(.system(size: 15))
Text("%")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(CooperationOrderColors.text666)
}
.padding(.horizontal, 12)
.frame(height: 46)
.background(CooperationOrderColors.searchBg, in: RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 8) {
Text("验证码发送手机号")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text666)
Text(maskAcquirerPhone(acquirer.displayMaskedPhone))
.font(.system(size: 15))
.foregroundStyle(CooperationOrderColors.text333)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, minHeight: 46, alignment: .leading)
.background(CooperationOrderColors.searchBg, in: RoundedRectangle(cornerRadius: 8))
}
VStack(alignment: .leading, spacing: 8) {
Text("验证码")
.font(.system(size: 13))
.foregroundStyle(CooperationOrderColors.text666)
HStack(spacing: 10) {
TextField("请输入验证码", text: $smsCode)
.keyboardType(.numberPad)
.font(.system(size: 15))
.padding(.horizontal, 12)
.frame(height: 46)
.background(CooperationOrderColors.searchBg, in: RoundedRectangle(cornerRadius: 8))
Button(smsButtonTitle) {
onSendSmsCode()
}
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.frame(width: 104, height: 46)
.background(smsButtonEnabled ? CooperationOrderColors.primary : CooperationOrderColors.primary.opacity(0.45), in: RoundedRectangle(cornerRadius: 8))
.disabled(!smsButtonEnabled)
}
}
HStack(spacing: 12) {
Button("取消") {
onCancel()
}
.font(.system(size: 15, weight: .medium))
.foregroundStyle(CooperationOrderColors.text666)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(Color(hex: 0xF1F3F5), in: RoundedRectangle(cornerRadius: 8))
Button(submitting ? "提交中..." : "确认修改") {
onConfirm()
}
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 44)
.background(CooperationOrderColors.primary, in: RoundedRectangle(cornerRadius: 8))
.disabled(submitting)
}
.padding(.top, 4)
}
.padding(.horizontal, 18)
.padding(.bottom, 20)
}
private var smsButtonTitle: String {
if smsCountdown > 0 { return "\(smsCountdown)" }
return sending ? "发送中" : "获取验证码"
}
private var smsButtonEnabled: Bool {
smsCountdown == 0 && !sending
}
}
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))"
}