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