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