拆分宣传页视图并扩充 Mock 数据,合作订单支持获客员备注名并修复线索图片网格尺寸。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 17:43:04 +08:00
parent 1b1e93b9ca
commit 328c4321f4
41 changed files with 2529 additions and 1436 deletions

View File

@ -33,6 +33,9 @@ protocol CooperationOrderServing {
/// 退 status=3
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws
///
func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws
}
@MainActor
@ -156,4 +159,18 @@ final class CooperationOrderAPI: CooperationOrderServing {
)
)
}
///
func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/sale-user/update-remark",
body: SaleUserUpdateRemarkRequest(
saleUserId: saleUserId,
photogRemarkName: photogRemarkName
)
)
)
}
}

View File

@ -31,6 +31,13 @@
2. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}`
3. `BindAcquirerView` 加载邀请信息 → 确认 → `POST .../sale-user/accept-bind`
### 获客员备注名
1. 合作获客员列表每行右上角「修改备注」→ 弹窗编辑备注名
2. 保存 → `POST .../sale-user/update-remark`Body`{ "sale_user_id": Int, "photog_remark_name": String }`
3. 展示规则:优先 `photog_remark_name`,为空时回退获客员原名(`name` / `saler_name` / `sale_user_name`
4. 绑定确认页(`BindAcquirerView`)仍展示真实获客员名,不使用备注名
## 与订单模块关系
订单管理(`orderType` 11 摄影师跟拍 / 14 线下扫码)在订单卡片展示「订单来源」:
@ -43,7 +50,7 @@
| 文件 | 说明 |
| --- | --- |
| `API/CooperationOrderAPI.swift` | 8 个 sale-user / cooperative 接口 |
| `API/CooperationOrderAPI.swift` | 9 个 sale-user / cooperative 接口 |
| `Models/CooperationOrderModels.swift` | 实体与解码 |
| `Services/CooperationOrderFeature.swift` | 权限 URI 常量 |
| `ViewModels/*` | 列表、获客员、绑定 ViewModel |

View File

@ -13,6 +13,11 @@ extension String {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
}
/// 退
func saleUserDisplayName(primaryName: String, fallbackName: String = "") -> String {
cooperationNonEmptyOrDefault(primaryName.cooperationNonEmptyOrDefault(fallbackName))
}
}
///
@ -20,6 +25,17 @@ struct ReferralOrderStatusUpdateRequest: Encodable {
let status: Int
}
///
struct SaleUserUpdateRemarkRequest: Encodable {
let saleUserId: Int
let photogRemarkName: String
enum CodingKeys: String, CodingKey {
case saleUserId = "sale_user_id"
case photogRemarkName = "photog_remark_name"
}
}
///
struct CooperativeSalerListPayload: Decodable {
let total: Int
@ -49,6 +65,7 @@ struct CooperativeSaler: Decodable, Identifiable {
let bindingId: Int
let name: String
let salerName: String
let photogRemarkName: String
let phone: String
let phoneMasked: String
let salerPhone: String
@ -62,7 +79,7 @@ struct CooperativeSaler: Decodable, Identifiable {
var displayId: Int { saleUserId }
var displayName: String {
name.cooperationNonEmptyOrDefault(salerName)
photogRemarkName.saleUserDisplayName(primaryName: name, fallbackName: salerName)
}
var displayPhone: String {
@ -78,6 +95,7 @@ struct CooperativeSaler: Decodable, Identifiable {
case bindingId = "binding_id"
case name
case salerName = "saler_name"
case photogRemarkName = "photog_remark_name"
case phone
case phoneMasked = "phone_masked"
case salerPhone = "saler_phone"
@ -93,6 +111,7 @@ struct CooperativeSaler: Decodable, Identifiable {
bindingId = try container.decodeLossyInt(forKey: .bindingId) ?? 0
name = try container.decodeLossyString(forKey: .name)
salerName = try container.decodeLossyString(forKey: .salerName)
photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName)
phone = try container.decodeLossyString(forKey: .phone)
phoneMasked = try container.decodeLossyString(forKey: .phoneMasked)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
@ -101,6 +120,52 @@ struct CooperativeSaler: Decodable, Identifiable {
bindTime = try container.decodeLossyString(forKey: .bindTime)
boundAt = try container.decodeLossyString(forKey: .boundAt)
}
///
func withPhotogRemarkName(_ remark: String) -> CooperativeSaler {
CooperativeSaler(
saleUserId: saleUserId,
bindingId: bindingId,
name: name,
salerName: salerName,
photogRemarkName: remark,
phone: phone,
phoneMasked: phoneMasked,
salerPhone: salerPhone,
shareCode: shareCode,
storeId: storeId,
bindTime: bindTime,
boundAt: boundAt
)
}
init(
saleUserId: Int,
bindingId: Int,
name: String,
salerName: String,
photogRemarkName: String,
phone: String,
phoneMasked: String,
salerPhone: String,
shareCode: String,
storeId: Int,
bindTime: String,
boundAt: String
) {
self.saleUserId = saleUserId
self.bindingId = bindingId
self.name = name
self.salerName = salerName
self.photogRemarkName = photogRemarkName
self.phone = phone
self.phoneMasked = phoneMasked
self.salerPhone = salerPhone
self.shareCode = shareCode
self.storeId = storeId
self.bindTime = bindTime
self.boundAt = boundAt
}
}
///
@ -231,6 +296,7 @@ struct AcquisitionOrder: Decodable, Identifiable {
let orderStatusName: String
let status: String
let saleUserName: String
let photogRemarkName: String
let partnerName: String
let partnerRole: String
let remark: String
@ -249,7 +315,8 @@ struct AcquisitionOrder: Decodable, Identifiable {
}
var displayPartner: String {
if !saleUserName.isEmpty { return saleUserName }
let salerDisplayName = photogRemarkName.saleUserDisplayName(primaryName: saleUserName)
if !salerDisplayName.isEmpty { return salerDisplayName }
if !partnerName.isEmpty { return partnerName }
return ""
}
@ -270,6 +337,7 @@ struct AcquisitionOrder: Decodable, Identifiable {
case orderStatusName = "order_status_name"
case status
case saleUserName = "sale_user_name"
case photogRemarkName = "photog_remark_name"
case partnerName = "partner_name"
case partnerRole = "partner_role"
case remark
@ -293,6 +361,7 @@ struct AcquisitionOrder: Decodable, Identifiable {
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
status = try container.decodeLossyString(forKey: .status)
saleUserName = try container.decodeLossyString(forKey: .saleUserName)
photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName)
partnerName = try container.decodeLossyString(forKey: .partnerName)
partnerRole = try container.decodeLossyString(forKey: .partnerRole)
remark = try container.decodeLossyString(forKey: .remark)
@ -304,11 +373,17 @@ struct AcquisitionOrder: Decodable, Identifiable {
struct ReferralOrderSaler: Decodable {
let saleUserId: Int
let salerName: String
let photogRemarkName: String
let salerPhone: String
var displayName: String {
photogRemarkName.saleUserDisplayName(primaryName: salerName)
}
enum CodingKeys: String, CodingKey {
case saleUserId = "sale_user_id"
case salerName = "saler_name"
case photogRemarkName = "photog_remark_name"
case salerPhone = "saler_phone"
}
@ -316,6 +391,7 @@ struct ReferralOrderSaler: Decodable {
let container = try decoder.container(keyedBy: CodingKeys.self)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
salerName = try container.decodeLossyString(forKey: .salerName)
photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
}
}
@ -394,6 +470,7 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable {
let orderNo: String
let saleUserId: Int
let salerName: String
let photogRemarkName: String
let salerPhone: String
let displayText: String
let userMobile: String
@ -405,11 +482,16 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable {
let statusLabel: String
let createdAt: String
var displaySalerName: String {
photogRemarkName.saleUserDisplayName(primaryName: salerName)
}
enum CodingKeys: String, CodingKey {
case id
case orderNo = "order_no"
case saleUserId = "sale_user_id"
case salerName = "saler_name"
case photogRemarkName = "photog_remark_name"
case salerPhone = "saler_phone"
case displayText = "display_text"
case userMobile = "user_mobile"
@ -428,6 +510,7 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable {
orderNo = try container.decodeLossyString(forKey: .orderNo)
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
salerName = try container.decodeLossyString(forKey: .salerName)
photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName)
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
displayText = try container.decodeLossyString(forKey: .displayText)
userMobile = try container.decodeLossyString(forKey: .userMobile)

View File

@ -27,4 +27,19 @@ final class CooperationAcquirerViewModel: ObservableObject {
}
hasLoadedOnce = true
}
///
func updateRemark(
saleUserId: Int,
remark: String,
api: CooperationOrderServing
) async throws {
guard saleUserId > 0 else { return }
let trimmedRemark = remark.trimmingCharacters(in: .whitespacesAndNewlines)
try await api.saleUserUpdateRemark(saleUserId: saleUserId, photogRemarkName: trimmedRemark)
acquirers = acquirers.map { acquirer in
guard acquirer.saleUserId == saleUserId else { return acquirer }
return acquirer.withPhotogRemarkName(trimmedRemark)
}
}
}

View File

@ -16,6 +16,9 @@ struct CooperationAcquirerView: View {
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = CooperationAcquirerViewModel()
@State private var showScanner = false
@State private var showRemarkAlert = false
@State private var remarkEditingAcquirer: CooperativeSaler?
@State private var remarkDraft = ""
let onBind: (Int) -> Void
var body: some View {
@ -31,6 +34,10 @@ struct CooperationAcquirerView: View {
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
CooperativeSalerCard(acquirer: acquirer) { phone in
dialPhone(phone)
} onEditRemark: {
remarkEditingAcquirer = acquirer
remarkDraft = acquirer.photogRemarkName
showRemarkAlert = true
}
}
}
@ -73,6 +80,44 @@ struct CooperationAcquirerView: View {
.task {
await reload(showLoading: true)
}
.alert("修改备注", isPresented: $showRemarkAlert) {
TextField("备注名", text: $remarkDraft)
Button("取消", role: .cancel) {
remarkEditingAcquirer = nil
}
Button("保存") {
// alert dismiss state
guard let acquirer = remarkEditingAcquirer else { return }
let saleUserId = acquirer.saleUserId
let draft = remarkDraft
showRemarkAlert = false
remarkEditingAcquirer = nil
Task {
await saveRemark(saleUserId: saleUserId, draft: draft)
}
}
} message: {
if let acquirer = remarkEditingAcquirer {
Text("\(acquirer.salerName.cooperationNonEmptyOrDefault(acquirer.name)) 设置备注名")
}
}
}
///
private func saveRemark(saleUserId: Int, draft: String) async {
guard saleUserId > 0 else { return }
await globalLoading.withOptionalLoading(true) {
do {
try await viewModel.updateRemark(
saleUserId: saleUserId,
remark: draft,
api: cooperationOrderAPI
)
toastCenter.show("备注已保存")
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
private func reload(showLoading: Bool) async {

View File

@ -218,26 +218,37 @@ struct ReferralLeadImageGrid: View {
} else {
HStack(spacing: 8) {
ForEach(0..<3, id: \.self) { index in
if index < images.count, let url = URL(string: images[index]) {
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()
.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)
}
}
} 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)
}
}
}
@ -290,35 +301,46 @@ struct AcquisitionOrderCard: View {
struct CooperativeSalerCard: View {
let acquirer: CooperativeSaler
let onDial: (String) -> Void
let onEditRemark: () -> 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))
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
)
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(.leading, 12)
.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)
}
.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)
}
}