新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
380 lines
14 KiB
Swift
380 lines
14 KiB
Swift
//
|
|
// 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))"
|
|
}
|