迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。
新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -123,6 +123,10 @@ private struct WalletAPIEnvironmentKey: EnvironmentKey {
|
||||
static var defaultValue: WalletAPI { WalletAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||
}
|
||||
|
||||
private struct CooperationOrderAPIEnvironmentKey: EnvironmentKey {
|
||||
static var defaultValue: CooperationOrderAPI { CooperationOrderAPI(client: EnvironmentServiceDefaults.apiClient()) }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var apiClient: APIClient {
|
||||
get { self[APIClientEnvironmentKey.self] }
|
||||
@ -258,4 +262,9 @@ extension EnvironmentValues {
|
||||
get { self[WalletAPIEnvironmentKey.self] }
|
||||
set { self[WalletAPIEnvironmentKey.self] = newValue }
|
||||
}
|
||||
|
||||
var cooperationOrderAPI: CooperationOrderAPI {
|
||||
get { self[CooperationOrderAPIEnvironmentKey.self] }
|
||||
set { self[CooperationOrderAPIEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ struct RootView: View {
|
||||
@State private var assetsAPI: AssetsAPI
|
||||
@State private var punchPointAPI: PunchPointAPI
|
||||
@State private var locationReportAPI: LocationReportAPI
|
||||
@State private var cooperationOrderAPI: CooperationOrderAPI
|
||||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||||
@State private var sessionBootstrapper: SessionBootstrapper
|
||||
|
||||
@ -79,6 +80,7 @@ struct RootView: View {
|
||||
_assetsAPI = State(initialValue: AssetsAPI(client: apiClient))
|
||||
_punchPointAPI = State(initialValue: PunchPointAPI(client: apiClient))
|
||||
_locationReportAPI = State(initialValue: LocationReportAPI(client: apiClient))
|
||||
_cooperationOrderAPI = State(initialValue: CooperationOrderAPI(client: apiClient))
|
||||
_authSessionCoordinator = State(
|
||||
initialValue: AuthSessionCoordinator(
|
||||
tokenStore: tokenStore,
|
||||
@ -132,6 +134,7 @@ struct RootView: View {
|
||||
.environment(\.assetsAPI, assetsAPI)
|
||||
.environment(\.punchPointAPI, punchPointAPI)
|
||||
.environment(\.locationReportAPI, locationReportAPI)
|
||||
.environment(\.cooperationOrderAPI, cooperationOrderAPI)
|
||||
.environment(\.authSessionCoordinator, authSessionCoordinator)
|
||||
.task {
|
||||
apiClient.bindAuthTokenProvider { appSession.token }
|
||||
|
||||
@ -23,7 +23,8 @@ struct AccountContextLoader {
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing,
|
||||
cachedCurrentRoleId: Int? = nil,
|
||||
cachedCurrentRoleCode: String? = nil,
|
||||
cachedLegacyRoleId: Int? = nil,
|
||||
cachedCurrentScenicId: Int? = nil,
|
||||
cachedCurrentStoreId: Int? = nil
|
||||
) async throws {
|
||||
@ -31,7 +32,11 @@ struct AccountContextLoader {
|
||||
async let roleData = accountContextAPI.rolePermissions()
|
||||
let (userInfo, rolePermissions) = try await (userData, roleData)
|
||||
|
||||
permissionContext.replaceRolePermissions(rolePermissions, currentRoleId: cachedCurrentRoleId)
|
||||
permissionContext.replaceRolePermissions(
|
||||
rolePermissions,
|
||||
currentRoleCode: cachedCurrentRoleCode,
|
||||
cachedLegacyRoleId: cachedLegacyRoleId
|
||||
)
|
||||
|
||||
let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions)
|
||||
let storesResponse = await loadStores(accountContextAPI: accountContextAPI)
|
||||
|
||||
@ -73,6 +73,7 @@ final class AuthSessionCoordinator {
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentRoleCode: response.currentAccountRoleCode,
|
||||
cachedCurrentScenicId: accountContext.currentScenic?.id,
|
||||
cachedCurrentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
@ -114,7 +115,7 @@ final class AuthSessionCoordinator {
|
||||
profile: profile,
|
||||
accountType: snapshotStore.load()?.accountType,
|
||||
businessUserId: snapshotStore.load()?.businessUserId,
|
||||
currentRoleId: snapshotStore.load()?.currentRoleId,
|
||||
currentRoleCode: snapshotStore.load()?.currentRoleCode,
|
||||
accountContext: accountContext
|
||||
)
|
||||
}
|
||||
@ -144,7 +145,7 @@ final class AuthSessionCoordinator {
|
||||
accountType: String?,
|
||||
businessUserId: Int?,
|
||||
permissionContext: PermissionContext? = nil,
|
||||
currentRoleId: Int? = nil,
|
||||
currentRoleCode: String? = nil,
|
||||
accountContext: AccountContext
|
||||
) {
|
||||
snapshotStore.save(
|
||||
@ -152,7 +153,7 @@ final class AuthSessionCoordinator {
|
||||
profile: profile,
|
||||
accountType: accountType,
|
||||
businessUserId: businessUserId,
|
||||
currentRoleId: permissionContext?.currentRole?.id ?? currentRoleId,
|
||||
currentRoleCode: permissionContext?.currentAppRole?.rawValue ?? currentRoleCode,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
|
||||
@ -15,20 +15,94 @@ final class PermissionContext: ObservableObject {
|
||||
@Published private(set) var permissionURIs: Set<String> = []
|
||||
@Published var currentRole: RoleInfo?
|
||||
|
||||
/// 替换角色权限列表,并尽量按缓存角色 ID 保持当前角色。
|
||||
func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) {
|
||||
/// 当前 App 业务角色,优先 role_code,兼容 legacy id 与角色名。
|
||||
var currentAppRole: AppRoleCode? {
|
||||
if let code = AppRoleCode.fromCode(currentRole?.roleCode) { return code }
|
||||
if let roleId = currentRole?.id, let code = AppRoleCode.fromLegacyRoleId(roleId) { return code }
|
||||
return AppRoleCode.fromRoleName(currentRole?.name)
|
||||
}
|
||||
|
||||
/// 替换角色权限列表,并按 role_code 优先恢复当前角色。
|
||||
func replaceRolePermissions(
|
||||
_ rolePermissions: [RolePermissionResponse],
|
||||
currentRoleCode: String? = nil,
|
||||
cachedLegacyRoleId: Int? = nil,
|
||||
roleName: String? = nil
|
||||
) {
|
||||
self.rolePermissions = rolePermissions
|
||||
currentRole = currentRoleId.flatMap { id in
|
||||
rolePermissions.first { $0.role.id == id }?.role
|
||||
} ?? currentRole.flatMap { role in
|
||||
rolePermissions.first { $0.role.id == role.id }?.role
|
||||
} ?? rolePermissions.first?.role
|
||||
let resolvedCode = RolePermissionMatching.resolveRoleCode(
|
||||
roleCode: currentRoleCode,
|
||||
roleId: cachedLegacyRoleId,
|
||||
roleName: roleName,
|
||||
permissions: rolePermissions
|
||||
)?.rawValue ?? currentRoleCode
|
||||
|
||||
if let matched = RolePermissionMatching.match(
|
||||
in: rolePermissions,
|
||||
roleCode: resolvedCode,
|
||||
roleId: cachedLegacyRoleId,
|
||||
roleName: roleName ?? currentRole?.name
|
||||
) {
|
||||
currentRole = matched.role
|
||||
} else if let currentRole,
|
||||
let matched = rolePermissions.first(where: { $0.role.id == currentRole.id }) {
|
||||
self.currentRole = matched.role
|
||||
} else {
|
||||
currentRole = rolePermissions.first?.role
|
||||
}
|
||||
permissionURIs = Set(Self.flattenPermissions(currentRole?.permission ?? []))
|
||||
}
|
||||
|
||||
/// 切换当前角色,并同步账号上下文中的景区和门店选择。
|
||||
func selectRole(code: AppRoleCode, accountContext: AccountContext) {
|
||||
guard let rolePermission = rolePermissions.first(where: {
|
||||
AppRoleCode.fromCode($0.role.roleCode) == code
|
||||
}) else { return }
|
||||
applyRoleSelection(rolePermission, accountContext: accountContext)
|
||||
}
|
||||
|
||||
/// 切换当前角色(legacy id),供权限申请等仍使用 API role id 的场景。
|
||||
func selectRole(id roleId: Int, accountContext: AccountContext) {
|
||||
guard let rolePermission = rolePermissions.first(where: { $0.role.id == roleId }) else { return }
|
||||
applyRoleSelection(rolePermission, accountContext: accountContext)
|
||||
}
|
||||
|
||||
/// 判断当前角色是否拥有指定 URI 权限。
|
||||
func canAccess(_ uri: String) -> Bool {
|
||||
permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
/// 返回指定角色的顶层权限 URI 列表,顺序与 API 返回一致。
|
||||
func topLevelPermissionURIs(for roleCode: String?) -> [String] {
|
||||
guard let roleCode,
|
||||
let rolePermission = matchedRolePermission(roleCode: roleCode) else {
|
||||
return []
|
||||
}
|
||||
return rolePermission.role.permission.compactMap { item in
|
||||
let uri = item.uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return uri.isEmpty ? nil : uri
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回当前角色关联的景区作用域。
|
||||
func currentRoleScenicScopes() -> [BusinessScope] {
|
||||
guard let currentRole else { return [] }
|
||||
guard let rolePermission = matchedRolePermission(roleCode: currentRole.roleCode)
|
||||
?? rolePermissions.first(where: { $0.role.id == currentRole.id }) else {
|
||||
return []
|
||||
}
|
||||
return Self.uniqueScenicScopes(from: rolePermission.scenic)
|
||||
}
|
||||
|
||||
/// 清空权限上下文,通常在退出登录时调用。
|
||||
func reset() {
|
||||
rolePermissions = []
|
||||
permissionURIs = []
|
||||
currentRole = nil
|
||||
}
|
||||
|
||||
/// 应用角色切换并同步景区作用域。
|
||||
private func applyRoleSelection(_ rolePermission: RolePermissionResponse, accountContext: AccountContext) {
|
||||
currentRole = rolePermission.role
|
||||
permissionURIs = Set(Self.flattenPermissions(rolePermission.role.permission))
|
||||
|
||||
@ -41,25 +115,14 @@ final class PermissionContext: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断当前角色是否拥有指定 URI 权限。
|
||||
func canAccess(_ uri: String) -> Bool {
|
||||
permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
/// 返回当前角色关联的景区作用域。
|
||||
func currentRoleScenicScopes() -> [BusinessScope] {
|
||||
guard let currentRole else { return [] }
|
||||
guard let rolePermission = rolePermissions.first(where: { $0.role.id == currentRole.id }) else {
|
||||
return []
|
||||
}
|
||||
return Self.uniqueScenicScopes(from: rolePermission.scenic)
|
||||
}
|
||||
|
||||
/// 清空权限上下文,通常在退出登录时调用。
|
||||
func reset() {
|
||||
rolePermissions = []
|
||||
permissionURIs = []
|
||||
currentRole = nil
|
||||
/// 按 role_code 在权限列表中查找角色权限项。
|
||||
private func matchedRolePermission(roleCode: String) -> RolePermissionResponse? {
|
||||
RolePermissionMatching.match(
|
||||
in: rolePermissions,
|
||||
roleCode: roleCode,
|
||||
roleId: nil,
|
||||
roleName: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// 递归提取权限树里的非空 URI。
|
||||
|
||||
@ -58,7 +58,8 @@ final class SessionBootstrapper {
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentRoleId: cachedSnapshot?.currentRoleId,
|
||||
cachedCurrentRoleCode: cachedSnapshot?.currentRoleCode,
|
||||
cachedLegacyRoleId: cachedSnapshot?.currentRoleId,
|
||||
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
|
||||
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
|
||||
)
|
||||
@ -100,7 +101,7 @@ final class SessionBootstrapper {
|
||||
profile: accountContext.profile,
|
||||
accountType: existing?.accountType,
|
||||
businessUserId: existing?.businessUserId,
|
||||
currentRoleId: permissionContext.currentRole?.id ?? existing?.currentRoleId,
|
||||
currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
|
||||
100
suixinkan/Core/Models/AppRoleCode.swift
Normal file
100
suixinkan/Core/Models/AppRoleCode.swift
Normal file
@ -0,0 +1,100 @@
|
||||
//
|
||||
// AppRoleCode.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// App 业务角色枚举,与后端 `app_role_code` / `role_code` 字符串一一对应。
|
||||
enum AppRoleCode: String, CaseIterable, Hashable {
|
||||
case driver = "driver"
|
||||
case floristBuilder = "florist_builder"
|
||||
case clerk = "clerk"
|
||||
case liveStream = "live_stream"
|
||||
case frontDesk = "front_desk"
|
||||
case makeupArtist = "makeup_artist"
|
||||
case photographerAssistant = "photographer_assistant"
|
||||
case assistantManager = "assistant_manager"
|
||||
case scenicOperation = "scenic_operation"
|
||||
case scenicAdmin = "scenic_admin"
|
||||
case scenicCS = "scenic_cs"
|
||||
case editor = "editor"
|
||||
case storeAdmin = "store_admin"
|
||||
case photographer = "photographer"
|
||||
|
||||
/// 角色中文展示名。
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .driver: "司机"
|
||||
case .floristBuilder: "搭建花艺师"
|
||||
case .clerk: "店员"
|
||||
case .liveStream: "直播"
|
||||
case .frontDesk: "前台"
|
||||
case .makeupArtist: "化妆师"
|
||||
case .photographerAssistant: "摄影师助理"
|
||||
case .assistantManager: "副店长"
|
||||
case .scenicOperation: "景区运营"
|
||||
case .scenicAdmin: "景区管理员"
|
||||
case .scenicCS: "飞手"
|
||||
case .editor: "剪辑师"
|
||||
case .storeAdmin: "店铺管理员"
|
||||
case .photographer: "摄影师"
|
||||
}
|
||||
}
|
||||
|
||||
var isStoreAdmin: Bool { self == .storeAdmin }
|
||||
var isScenicAdmin: Bool { self == .scenicAdmin }
|
||||
var isPhotographer: Bool { self == .photographer }
|
||||
|
||||
/// 从后端 role_code 字符串解析角色。
|
||||
static func fromCode(_ code: String?) -> AppRoleCode? {
|
||||
guard let trimmed = code?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!trimmed.isEmpty else { return nil }
|
||||
return AppRoleCode(rawValue: trimmed)
|
||||
}
|
||||
|
||||
/// 从旧版 numeric role id 解析角色,用于一次性缓存迁移。
|
||||
static func fromLegacyRoleId(_ roleId: Int) -> AppRoleCode? {
|
||||
switch roleId {
|
||||
case 41: .photographer
|
||||
case 46: .storeAdmin
|
||||
case 47: .editor
|
||||
case 52: .scenicCS
|
||||
case 53: .scenicAdmin
|
||||
case 54: .scenicOperation
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 从角色中文名推断角色,作为 role_code 缺失时的兜底。
|
||||
static func fromRoleName(_ roleName: String?) -> AppRoleCode? {
|
||||
let name = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !name.isEmpty else { return nil }
|
||||
if name.contains("摄影师助理") { return .photographerAssistant }
|
||||
if name.contains("副店长") { return .assistantManager }
|
||||
if name.contains("搭建花艺") || name.contains("花艺师") { return .floristBuilder }
|
||||
if name.contains("店铺管理") || name.contains("店长") { return .storeAdmin }
|
||||
if name.contains("景区管理") { return .scenicAdmin }
|
||||
if name.contains("景区运营") { return .scenicOperation }
|
||||
if name.contains("景区客服") || name.contains("飞手") { return .scenicCS }
|
||||
if name.contains("剪辑") { return .editor }
|
||||
if name.contains("化妆师") { return .makeupArtist }
|
||||
if name.contains("摄影师") { return .photographer }
|
||||
if name.contains("司机") { return .driver }
|
||||
if name.contains("店员") { return .clerk }
|
||||
if name.contains("直播") { return .liveStream }
|
||||
if name.contains("前台") { return .frontDesk }
|
||||
return AppRoleCode.allCases.first { name.contains($0.displayName) }
|
||||
}
|
||||
|
||||
/// 首页隐藏顶部在线/计时/位置/快捷操作的角色(原 46/47/52/53/54)。
|
||||
static let homeMinimalTopRoles: Set<AppRoleCode> = [
|
||||
.storeAdmin,
|
||||
.editor,
|
||||
.scenicCS,
|
||||
.scenicAdmin,
|
||||
.scenicOperation,
|
||||
]
|
||||
}
|
||||
76
suixinkan/Core/Models/RolePermissionMatching.swift
Normal file
76
suixinkan/Core/Models/RolePermissionMatching.swift
Normal file
@ -0,0 +1,76 @@
|
||||
//
|
||||
// RolePermissionMatching.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 角色权限匹配工具,按 role_code 优先、legacy id、角色名顺序解析当前角色。
|
||||
enum RolePermissionMatching {
|
||||
/// 解析当前 App 角色,用于快照迁移和 session 补全。
|
||||
static func resolveRoleCode(
|
||||
roleCode: String?,
|
||||
roleId: Int?,
|
||||
roleName: String?,
|
||||
permissions: [RolePermissionResponse] = []
|
||||
) -> AppRoleCode? {
|
||||
if let code = AppRoleCode.fromCode(roleCode) { return code }
|
||||
if let legacyId = roleId, legacyId > 0, let code = AppRoleCode.fromLegacyRoleId(legacyId) {
|
||||
return code
|
||||
}
|
||||
if let code = AppRoleCode.fromRoleName(roleName) { return code }
|
||||
return inferFromPermissionList(roleId: roleId, roleName: roleName, permissions: permissions)
|
||||
}
|
||||
|
||||
/// 在角色权限列表中匹配当前角色,优先 role_code。
|
||||
static func match(
|
||||
in permissions: [RolePermissionResponse],
|
||||
roleCode: String?,
|
||||
roleId: Int?,
|
||||
roleName: String?
|
||||
) -> RolePermissionResponse? {
|
||||
if let code = AppRoleCode.fromCode(roleCode) {
|
||||
if let matched = permissions.first(where: { AppRoleCode.fromCode($0.role.roleCode) == code }) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
if let roleId, roleId > 0,
|
||||
let matched = permissions.first(where: { $0.role.id == roleId }) {
|
||||
return matched
|
||||
}
|
||||
let trimmedName = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedName.isEmpty {
|
||||
if let matched = permissions.first(where: { item in
|
||||
let name = item.role.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !name.isEmpty && (name.contains(trimmedName) || trimmedName.contains(name))
|
||||
}) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 从权限列表推断角色 code。
|
||||
private static func inferFromPermissionList(
|
||||
roleId: Int?,
|
||||
roleName: String?,
|
||||
permissions: [RolePermissionResponse]
|
||||
) -> AppRoleCode? {
|
||||
guard !permissions.isEmpty else { return nil }
|
||||
if let roleId, roleId > 0,
|
||||
let item = permissions.first(where: { $0.role.id == roleId }) {
|
||||
return AppRoleCode.fromCode(item.role.roleCode) ?? AppRoleCode.fromRoleName(item.role.name)
|
||||
}
|
||||
let trimmedName = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedName.isEmpty,
|
||||
let item = permissions.first(where: { item in
|
||||
let name = item.role.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !name.isEmpty && (name.contains(trimmedName) || trimmedName.contains(name))
|
||||
}) {
|
||||
return AppRoleCode.fromCode(item.role.roleCode) ?? AppRoleCode.fromRoleName(item.role.name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,8 @@ struct AccountSnapshot: Codable, Equatable {
|
||||
var profile: AccountProfile?
|
||||
var accountType: String?
|
||||
var businessUserId: Int?
|
||||
var currentRoleCode: String?
|
||||
/// 旧版快照字段,仅用于解码与一次性迁移。
|
||||
var currentRoleId: Int?
|
||||
var scenicScopes: [BusinessScope]
|
||||
var storeScopes: [BusinessScope]
|
||||
@ -24,6 +26,7 @@ struct AccountSnapshot: Codable, Equatable {
|
||||
profile: AccountProfile? = nil,
|
||||
accountType: String? = nil,
|
||||
businessUserId: Int? = nil,
|
||||
currentRoleCode: String? = nil,
|
||||
currentRoleId: Int? = nil,
|
||||
scenicScopes: [BusinessScope] = [],
|
||||
storeScopes: [BusinessScope] = [],
|
||||
@ -33,12 +36,25 @@ struct AccountSnapshot: Codable, Equatable {
|
||||
self.profile = profile
|
||||
self.accountType = accountType
|
||||
self.businessUserId = businessUserId
|
||||
self.currentRoleCode = currentRoleCode
|
||||
self.currentRoleId = currentRoleId
|
||||
self.scenicScopes = scenicScopes
|
||||
self.storeScopes = storeScopes
|
||||
self.currentScenicId = currentScenicId
|
||||
self.currentStoreId = currentStoreId
|
||||
}
|
||||
|
||||
/// 若快照仅有 legacy role id,则迁移为 role_code 并清除 id。
|
||||
mutating func migrateLegacyRoleIfNeeded() {
|
||||
guard AppRoleCode.fromCode(currentRoleCode) == nil,
|
||||
let legacyId = currentRoleId,
|
||||
legacyId > 0,
|
||||
let migrated = AppRoleCode.fromLegacyRoleId(legacyId) else {
|
||||
return
|
||||
}
|
||||
currentRoleCode = migrated.rawValue
|
||||
currentRoleId = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。
|
||||
@ -67,11 +83,17 @@ final class AccountSnapshotStore {
|
||||
defaults.set(data, forKey: key)
|
||||
}
|
||||
|
||||
/// 读取账号快照,解码失败时清空损坏数据。
|
||||
/// 读取账号快照,解码失败时清空损坏数据;读取后自动尝试 legacy 角色迁移。
|
||||
func load() -> AccountSnapshot? {
|
||||
guard let data = defaults.data(forKey: key) else { return nil }
|
||||
do {
|
||||
return try decoder.decode(AccountSnapshot.self, from: data)
|
||||
var snapshot = try decoder.decode(AccountSnapshot.self, from: data)
|
||||
let beforeCode = snapshot.currentRoleCode
|
||||
snapshot.migrateLegacyRoleIfNeeded()
|
||||
if snapshot.currentRoleCode != beforeCode {
|
||||
save(snapshot)
|
||||
}
|
||||
return snapshot
|
||||
} catch {
|
||||
clear()
|
||||
return nil
|
||||
@ -87,7 +109,7 @@ final class AccountSnapshotStore {
|
||||
@MainActor
|
||||
func saveCurrentSelection(
|
||||
accountContext: AccountContext,
|
||||
currentRoleId: Int?
|
||||
currentRoleCode: String?
|
||||
) {
|
||||
let existing = load()
|
||||
save(
|
||||
@ -95,7 +117,7 @@ final class AccountSnapshotStore {
|
||||
profile: accountContext.profile ?? existing?.profile,
|
||||
accountType: existing?.accountType,
|
||||
businessUserId: existing?.businessUserId,
|
||||
currentRoleId: currentRoleId ?? existing?.currentRoleId,
|
||||
currentRoleCode: currentRoleCode ?? existing?.currentRoleCode,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
|
||||
107
suixinkan/Core/Utils/SaleUserQrParser.swift
Normal file
107
suixinkan/Core/Utils/SaleUserQrParser.swift
Normal file
@ -0,0 +1,107 @@
|
||||
//
|
||||
// SaleUserQrParser.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 获客员绑定二维码解析器,从扫码内容提取 sale_user_id。
|
||||
enum SaleUserQrParser {
|
||||
/// 标准获客员绑定二维码 method,与后端约定一致。
|
||||
static let methodSaleUserBind = "sale_user_bind"
|
||||
|
||||
/// 从获客员二维码内容解析 sale_user_id。
|
||||
/// 标准格式:`{"method":"sale_user_bind","sale_user_id":2}`
|
||||
/// 兼容:纯数字、URL query、无 method 的历史 JSON。
|
||||
static func parseSaleUserId(_ content: String) -> Int? {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
|
||||
if trimmed.hasPrefix("{") {
|
||||
if let value = parseFromJSON(trimmed) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
if let value = Int(trimmed), value > 0 {
|
||||
return value
|
||||
}
|
||||
|
||||
if trimmed.contains("://") || trimmed.contains("?") {
|
||||
return parseFromURL(trimmed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 判断扫码内容是否为获客员绑定专用二维码。
|
||||
static func isSaleUserBindQR(_ content: String) -> Bool {
|
||||
guard let object = jsonObject(from: content) else { return false }
|
||||
let method = (object["method"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return method == methodSaleUserBind
|
||||
}
|
||||
|
||||
private static func parseFromJSON(_ json: String) -> Int? {
|
||||
guard let object = jsonObject(from: json) else { return nil }
|
||||
return parseFromJSONObject(object)
|
||||
}
|
||||
|
||||
private static func jsonObject(from json: String) -> [String: Any]? {
|
||||
guard let data = json.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return nil }
|
||||
return object
|
||||
}
|
||||
|
||||
private static func parseFromJSONObject(_ object: [String: Any]) -> Int? {
|
||||
let method = (object["method"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if method == methodSaleUserBind {
|
||||
return readIntValue(object["sale_user_id"])
|
||||
}
|
||||
return readSaleUserId(object)
|
||||
}
|
||||
|
||||
private static func readSaleUserId(_ object: [String: Any]) -> Int? {
|
||||
let keys = ["sale_user_id", "saleUserId", "user_id", "id"]
|
||||
for key in keys {
|
||||
if let value = readIntValue(object[key]) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func readIntValue(_ value: Any?) -> Int? {
|
||||
switch value {
|
||||
case let intValue as Int where intValue > 0:
|
||||
return intValue
|
||||
case let number as NSNumber where number.intValue > 0:
|
||||
return number.intValue
|
||||
case let stringValue as String:
|
||||
let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(trimmed), intValue > 0 {
|
||||
return intValue
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseFromURL(_ urlString: String) -> Int? {
|
||||
guard let components = URLComponents(string: urlString) else { return nil }
|
||||
let keys = ["sale_user_id", "saleUserId", "user_id", "id"]
|
||||
for key in keys {
|
||||
if let value = components.queryItems?.first(where: { $0.name == key })?.value,
|
||||
let intValue = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
intValue > 0 {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,7 @@ Account 模块负责同步账号进入业务页前必须具备的上下文数据
|
||||
登录成功或冷启动恢复时,`AccountContextLoader` 会并行请求用户资料和角色权限。权限成功后继续读取景区和门店:
|
||||
|
||||
1. 用户资料刷新 `AccountContext.profile`。
|
||||
2. 角色权限刷新 `PermissionContext`,并计算当前角色的权限 URI 集合。
|
||||
2. 角色权限刷新 `PermissionContext`,按 `role_code` 优先匹配当前角色,并计算权限 URI 集合。
|
||||
3. 景区列表失败时,从角色权限里的景区数据去重兜底。
|
||||
4. 门店列表失败时不阻断登录,只写入空门店列表。
|
||||
5. 当前景区确定后,`ScenicSpotContext` 再按景区 ID 拉取景点/打卡点。
|
||||
@ -31,6 +31,8 @@ Account 模块负责同步账号进入业务页前必须具备的上下文数据
|
||||
|
||||
## 缓存规则
|
||||
|
||||
`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前角色 ID、当前景区 ID、当前门店 ID 和基础账号展示信息。
|
||||
`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前 `role_code`(`currentRoleCode`)、当前景区 ID、当前门店 ID 和基础账号展示信息。旧版快照中的 `currentRoleId` 会在读取时一次性迁移为 `role_code`。
|
||||
|
||||
角色匹配优先级:`role_code` → legacy `role_id` → 角色名 → 权限列表推断。登录时还会从 V9 响应的 `app_role_code` 写入初始角色 hint。
|
||||
|
||||
景点/打卡点列表只保存在内存中,不长期落盘。后续业务需要离线缓存时,需要按账号类型、业务账号 ID 和景区 ID 做隔离。
|
||||
|
||||
@ -17,6 +17,7 @@ struct RolePermissionResponse: Decodable, Equatable {
|
||||
struct RoleInfo: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let roleCode: String
|
||||
let notes: String?
|
||||
let permission: [PermissionItem]
|
||||
|
||||
@ -24,14 +25,16 @@ struct RoleInfo: Decodable, Equatable, Identifiable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case roleCode = "role_code"
|
||||
case notes
|
||||
case permission
|
||||
}
|
||||
|
||||
/// 创建角色实体,主要用于测试和本地状态恢复。
|
||||
init(id: Int, name: String, notes: String? = nil, permission: [PermissionItem] = []) {
|
||||
init(id: Int, name: String, roleCode: String = "", notes: String? = nil, permission: [PermissionItem] = []) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.roleCode = roleCode
|
||||
self.notes = notes
|
||||
self.permission = permission
|
||||
}
|
||||
@ -41,6 +44,7 @@ struct RoleInfo: Decodable, Equatable, Identifiable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
roleCode = try container.decodeLossyString(forKey: .roleCode)
|
||||
notes = try container.decodeIfPresent(String.self, forKey: .notes)
|
||||
permission = try container.decodeIfPresent([PermissionItem].self, forKey: .permission) ?? []
|
||||
}
|
||||
|
||||
@ -157,6 +157,17 @@ struct V9AuthResponse: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前账号的 app_role_code,用于登录后初始角色匹配。
|
||||
var currentAccountRoleCode: String? {
|
||||
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first {
|
||||
return nonEmpty(storeUser.resolvedAppRoleCode)
|
||||
}
|
||||
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first {
|
||||
return nonEmpty(scenicUser.resolvedAppRoleCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// v9 登录响应的 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case token
|
||||
@ -178,6 +189,11 @@ struct V9AuthResponse: Decodable, Equatable {
|
||||
scenicUsers = (try? container.decodeIfPresent([V9ScenicUser].self, forKey: .scenicUsers)) ?? []
|
||||
storeUsers = (try? container.decodeIfPresent([V9StoreUser].self, forKey: .storeUsers)) ?? []
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String) -> String? {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
/// v9 景区账号实体,表示用户可进入的某个景区身份。
|
||||
@ -196,6 +212,13 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
let scenicId: Int
|
||||
let scenicName: String
|
||||
let isCurrent: Bool
|
||||
let appRoleCode: String
|
||||
let appRoleName: String
|
||||
|
||||
/// 解析后的 app_role_code,优先顶层字段。
|
||||
var resolvedAppRoleCode: String {
|
||||
appRoleCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。
|
||||
var businessUserId: Int {
|
||||
@ -240,6 +263,8 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
case scenicId = "scenic_id"
|
||||
case scenicName = "scenic_name"
|
||||
case isCurrent = "is_current"
|
||||
case appRoleCode = "app_role_code"
|
||||
case appRoleName = "app_role_name"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。
|
||||
@ -257,6 +282,8 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
|
||||
scenicName = try container.decodeLossyString(forKey: .scenicName)
|
||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
|
||||
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
|
||||
}
|
||||
}
|
||||
|
||||
@ -278,6 +305,13 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
let storeId: Int
|
||||
let storeName: String
|
||||
let isCurrent: Bool
|
||||
let appRoleCode: String
|
||||
let appRoleName: String
|
||||
|
||||
/// 解析后的 app_role_code,优先顶层字段。
|
||||
var resolvedAppRoleCode: String {
|
||||
appRoleCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。
|
||||
var businessUserId: Int {
|
||||
@ -324,6 +358,8 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
case storeId = "store_id"
|
||||
case storeName = "store_name"
|
||||
case isCurrent = "is_current"
|
||||
case appRoleCode = "app_role_code"
|
||||
case appRoleName = "app_role_name"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。
|
||||
@ -343,6 +379,8 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
|
||||
storeName = try container.decodeLossyString(forKey: .storeName)
|
||||
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
||||
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
|
||||
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,159 @@
|
||||
//
|
||||
// CooperationOrderAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 合作订单服务协议,抽象获客员与带客单相关接口以便测试替换。
|
||||
protocol CooperationOrderServing {
|
||||
/// 获取合作获客员列表。
|
||||
func cooperativeSalers(storeId: Int?, page: Int, pageSize: Int) async throws -> CooperativeSalerListPayload
|
||||
|
||||
/// 获取获客员邀请信息。
|
||||
func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo
|
||||
|
||||
/// 接受获客员绑定。
|
||||
func saleUserAcceptBind(saleUserId: Int) async throws
|
||||
|
||||
/// 获取获客员线索列表。
|
||||
func saleUserReferralLeads(page: Int, pageSize: Int) async throws -> ListPayload<ReferralLead>
|
||||
|
||||
/// 获取获客订单列表。
|
||||
func saleUserAcquisitionOrders(keyword: String?, page: Int, pageSize: Int) async throws -> ListPayload<AcquisitionOrder>
|
||||
|
||||
/// 获取可绑定带客单列表。
|
||||
func saleUserReferralOrders(saleUserId: Int, page: Int, pageSize: Int) async throws -> ReferralOrderListPayload
|
||||
|
||||
/// 绑定带客单到订单。
|
||||
func saleUserBindReferralOrder(orderNumber: String, referralOrderNo: String) async throws
|
||||
|
||||
/// 更新带客单状态(如退款时 status=3)。
|
||||
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 合作订单 API,封装合作获客员、线索、获客订单与带客单绑定接口。
|
||||
final class CooperationOrderAPI: CooperationOrderServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化合作订单 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取合作获客员列表。
|
||||
func cooperativeSalers(storeId: Int? = nil, page: Int = 1, pageSize: Int = 10) async throws -> CooperativeSalerListPayload {
|
||||
var query = [
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let storeId, storeId > 0 {
|
||||
query.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/cooperative-salers",
|
||||
queryItems: query
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取获客员邀请信息。
|
||||
func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/sale-user/invite-info",
|
||||
queryItems: [URLQueryItem(name: "sale_user_id", value: "\(saleUserId)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 接受获客员绑定。
|
||||
func saleUserAcceptBind(saleUserId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/sale-user/accept-bind",
|
||||
queryItems: [URLQueryItem(name: "sale_user_id", value: "\(saleUserId)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取获客员线索列表。
|
||||
func saleUserReferralLeads(page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<ReferralLead> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/sale-user/referral-leads",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取获客订单列表。
|
||||
func saleUserAcquisitionOrders(keyword: String? = nil, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<AcquisitionOrder> {
|
||||
var query = [
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let keyword, !keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
query.append(URLQueryItem(name: "keyword", value: keyword))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/sale-user/acquisition-orders",
|
||||
queryItems: query
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取可绑定带客单列表。
|
||||
func saleUserReferralOrders(saleUserId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ReferralOrderListPayload {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/sale-user/referral-orders",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "sale_user_id", value: "\(saleUserId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 绑定带客单到订单。
|
||||
func saleUserBindReferralOrder(orderNumber: String, referralOrderNo: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/sale-user/bind-referral-order",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "order_number", value: orderNumber),
|
||||
URLQueryItem(name: "referral_order_no", value: referralOrderNo)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新带客单状态。
|
||||
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/sale-user/referral-orders/\(id)/status",
|
||||
body: ReferralOrderStatusUpdateRequest(status: status)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
57
suixinkan/Features/CooperationOrder/CooperationOrder.md
Normal file
57
suixinkan/Features/CooperationOrder/CooperationOrder.md
Normal file
@ -0,0 +1,57 @@
|
||||
# 合作订单模块
|
||||
|
||||
## 职责
|
||||
|
||||
合作订单模块面向摄影师/门店角色,管理合作获客业务:
|
||||
|
||||
- **获客员线索**:查看合作获客员上报的潜在客户线索(手机号、备注、图片)
|
||||
- **获客订单**:查看通过合作渠道产生的订单,支持关键词搜索
|
||||
- **合作获客员**:查看已绑定获客员列表,扫码绑定新获客员
|
||||
|
||||
权限 URI:`cooperation_order`(与 Android `CooperationOrderFeature.URI` 一致)。
|
||||
|
||||
## 核心流程
|
||||
|
||||
### 首页入口
|
||||
|
||||
1. 用户点击首页「合作订单」菜单(`cooperation_order`)
|
||||
2. `CooperationOrderAccessView` 校验 `PermissionContext.canAccess("cooperation_order")`
|
||||
3. 无权限时 Toast 提示并返回;有权限进入 `CooperationOrderListView`
|
||||
|
||||
### 双 Tab 列表
|
||||
|
||||
| Tab | API | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 获客员线索 | `GET .../sale-user/referral-leads` | 分页 10 条,下拉刷新/加载更多 |
|
||||
| 获客订单 | `GET .../sale-user/acquisition-orders` | 支持 `keyword` 搜索 |
|
||||
|
||||
### 获客员绑定
|
||||
|
||||
1. 列表页右上角「合作获客员」→ `CooperationAcquirerView`
|
||||
2. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}`
|
||||
3. `BindAcquirerView` 加载邀请信息 → 确认 → `POST .../sale-user/accept-bind`
|
||||
|
||||
## 与订单模块关系
|
||||
|
||||
订单管理(`orderType` 11 摄影师跟拍 / 14 线下扫码)在订单卡片展示「订单来源」:
|
||||
|
||||
- Sheet 内选择获客员 → 带客单 → `POST .../bind-referral-order`
|
||||
- 全页流程见 `ReferralOrderSelectView`
|
||||
- 订单退款成功后,若已绑定带客单,调用 `POST .../referral-orders/{id}/status`(status=3)
|
||||
|
||||
## 主要文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `API/CooperationOrderAPI.swift` | 8 个 sale-user / cooperative 接口 |
|
||||
| `Models/CooperationOrderModels.swift` | 实体与解码 |
|
||||
| `Services/CooperationOrderFeature.swift` | 权限 URI 常量 |
|
||||
| `ViewModels/*` | 列表、获客员、绑定 ViewModel |
|
||||
| `Views/*` | SwiftUI 页面与卡片组件 |
|
||||
|
||||
## 依赖
|
||||
|
||||
- `@Environment(\.cooperationOrderAPI)` 注入网络层
|
||||
- `@EnvironmentObject PermissionContext` 权限校验
|
||||
- `@EnvironmentObject ToastCenter` 用户提示
|
||||
- Kingfisher 加载线索图片
|
||||
@ -0,0 +1,517 @@
|
||||
//
|
||||
// CooperationOrderModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// 非空时返回自身,否则返回 fallback。
|
||||
func cooperationNonEmptyOrDefault(_ fallback: String) -> String {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? fallback : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/// 带客单状态更新请求体。
|
||||
struct ReferralOrderStatusUpdateRequest: Encodable {
|
||||
let status: Int
|
||||
}
|
||||
|
||||
/// 合作获客员列表响应。
|
||||
struct CooperativeSalerListPayload: Decodable {
|
||||
let total: Int
|
||||
let list: [CooperativeSaler]
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total
|
||||
case list
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
total = try container.decodeLossyInt(forKey: .total) ?? 0
|
||||
list = try container.decodeIfPresent([CooperativeSaler].self, forKey: .list) ?? []
|
||||
page = try container.decodeLossyInt(forKey: .page) ?? 0
|
||||
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 合作获客员实体。
|
||||
struct CooperativeSaler: Decodable, Identifiable {
|
||||
let saleUserId: Int
|
||||
let bindingId: Int
|
||||
let name: String
|
||||
let salerName: String
|
||||
let phone: String
|
||||
let phoneMasked: String
|
||||
let salerPhone: String
|
||||
let shareCode: String
|
||||
let storeId: Int
|
||||
let bindTime: String
|
||||
let boundAt: String
|
||||
|
||||
var id: Int { saleUserId }
|
||||
|
||||
var displayId: Int { saleUserId }
|
||||
|
||||
var displayName: String {
|
||||
name.cooperationNonEmptyOrDefault(salerName)
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
salerPhone.cooperationNonEmptyOrDefault(phone)
|
||||
}
|
||||
|
||||
var displayBindTime: String {
|
||||
bindTime.cooperationNonEmptyOrDefault(boundAt)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case saleUserId = "sale_user_id"
|
||||
case bindingId = "binding_id"
|
||||
case name
|
||||
case salerName = "saler_name"
|
||||
case phone
|
||||
case phoneMasked = "phone_masked"
|
||||
case salerPhone = "saler_phone"
|
||||
case shareCode = "share_code"
|
||||
case storeId = "store_id"
|
||||
case bindTime = "bind_time"
|
||||
case boundAt = "bound_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||||
bindingId = try container.decodeLossyInt(forKey: .bindingId) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
salerName = try container.decodeLossyString(forKey: .salerName)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
phoneMasked = try container.decodeLossyString(forKey: .phoneMasked)
|
||||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||||
shareCode = try container.decodeLossyString(forKey: .shareCode)
|
||||
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
|
||||
bindTime = try container.decodeLossyString(forKey: .bindTime)
|
||||
boundAt = try container.decodeLossyString(forKey: .boundAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获客员邀请信息,用于绑定确认页展示。
|
||||
struct SaleUserInviteInfo: Decodable {
|
||||
let saleUserId: Int
|
||||
let isBound: Bool
|
||||
let photographerName: String
|
||||
let photographerPhone: String
|
||||
let salerName: String
|
||||
let salerPhone: String
|
||||
let shareCode: String
|
||||
let subtitle: String
|
||||
let title: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case saleUserId = "sale_user_id"
|
||||
case isBound = "is_bound"
|
||||
case photographerName = "photographer_name"
|
||||
case photographerPhone = "photographer_phone"
|
||||
case salerName = "saler_name"
|
||||
case salerPhone = "saler_phone"
|
||||
case shareCode = "share_code"
|
||||
case subtitle
|
||||
case title
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||||
isBound = try container.decodeLossyBool(forKey: .isBound) ?? false
|
||||
photographerName = try container.decodeLossyString(forKey: .photographerName)
|
||||
photographerPhone = try container.decodeLossyString(forKey: .photographerPhone)
|
||||
salerName = try container.decodeLossyString(forKey: .salerName)
|
||||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||||
shareCode = try container.decodeLossyString(forKey: .shareCode)
|
||||
subtitle = try container.decodeLossyString(forKey: .subtitle)
|
||||
title = try container.decodeLossyString(forKey: .title)
|
||||
}
|
||||
|
||||
init(
|
||||
saleUserId: Int,
|
||||
isBound: Bool = false,
|
||||
photographerName: String = "",
|
||||
photographerPhone: String = "",
|
||||
salerName: String = "",
|
||||
salerPhone: String = "",
|
||||
shareCode: String = "",
|
||||
subtitle: String = "",
|
||||
title: String = ""
|
||||
) {
|
||||
self.saleUserId = saleUserId
|
||||
self.isBound = isBound
|
||||
self.photographerName = photographerName
|
||||
self.photographerPhone = photographerPhone
|
||||
self.salerName = salerName
|
||||
self.salerPhone = salerPhone
|
||||
self.shareCode = shareCode
|
||||
self.subtitle = subtitle
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/// 获客员线索实体。
|
||||
struct ReferralLead: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let leadId: Int
|
||||
let userPhone: String
|
||||
let userMobileMasked: String
|
||||
let phone: String
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
let imageUrls: [String]
|
||||
let images: [String]
|
||||
|
||||
var displayId: String {
|
||||
let value = leadId > 0 ? leadId : id
|
||||
return "\(value)"
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
userMobileMasked.cooperationNonEmptyOrDefault(userPhone.cooperationNonEmptyOrDefault(phone))
|
||||
}
|
||||
|
||||
var displayImages: [String] {
|
||||
imageUrls.isEmpty ? images : imageUrls
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case leadId = "lead_id"
|
||||
case userPhone = "user_phone"
|
||||
case userMobileMasked = "user_mobile_masked"
|
||||
case phone
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
case imageUrls = "image_urls"
|
||||
case images
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
leadId = try container.decodeLossyInt(forKey: .leadId) ?? 0
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
|
||||
images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 获客订单实体。
|
||||
struct AcquisitionOrder: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let orderNumber: String
|
||||
let projectName: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let orderAmount: String
|
||||
let amount: String
|
||||
let actualPayAmount: String
|
||||
let createdAt: String
|
||||
let customerPhone: String
|
||||
let userMobileMasked: String
|
||||
let phone: String
|
||||
let orderStatusName: String
|
||||
let status: String
|
||||
let saleUserName: String
|
||||
let partnerName: String
|
||||
let partnerRole: String
|
||||
let remark: String
|
||||
let imageUrls: [String]
|
||||
|
||||
var displayAmount: String {
|
||||
orderAmount.cooperationNonEmptyOrDefault(amount.cooperationNonEmptyOrDefault(actualPayAmount))
|
||||
}
|
||||
|
||||
var displayCustomerPhone: String {
|
||||
userMobileMasked.cooperationNonEmptyOrDefault(customerPhone.cooperationNonEmptyOrDefault(phone))
|
||||
}
|
||||
|
||||
var displayStatus: String {
|
||||
orderStatusName.cooperationNonEmptyOrDefault(status)
|
||||
}
|
||||
|
||||
var displayPartner: String {
|
||||
if !saleUserName.isEmpty { return saleUserName }
|
||||
if !partnerName.isEmpty { return partnerName }
|
||||
return ""
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderNumber = "order_number"
|
||||
case projectName = "project_name"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case orderAmount = "order_amount"
|
||||
case amount
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case createdAt = "created_at"
|
||||
case customerPhone = "customer_phone"
|
||||
case userMobileMasked = "user_mobile_masked"
|
||||
case phone
|
||||
case orderStatusName = "order_status_name"
|
||||
case status
|
||||
case saleUserName = "sale_user_name"
|
||||
case partnerName = "partner_name"
|
||||
case partnerRole = "partner_role"
|
||||
case remark
|
||||
case imageUrls = "image_urls"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
amount = try container.decodeLossyString(forKey: .amount)
|
||||
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
customerPhone = try container.decodeLossyString(forKey: .customerPhone)
|
||||
userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
status = try container.decodeLossyString(forKey: .status)
|
||||
saleUserName = try container.decodeLossyString(forKey: .saleUserName)
|
||||
partnerName = try container.decodeLossyString(forKey: .partnerName)
|
||||
partnerRole = try container.decodeLossyString(forKey: .partnerRole)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
imageUrls = try container.decodeIfPresent([String].self, forKey: .imageUrls) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 带客单列表中的获客员摘要。
|
||||
struct ReferralOrderSaler: Decodable {
|
||||
let saleUserId: Int
|
||||
let salerName: String
|
||||
let salerPhone: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case saleUserId = "sale_user_id"
|
||||
case salerName = "saler_name"
|
||||
case salerPhone = "saler_phone"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||||
salerName = try container.decodeLossyString(forKey: .salerName)
|
||||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||||
}
|
||||
}
|
||||
|
||||
/// 可绑定带客单列表响应。
|
||||
struct ReferralOrderListPayload: Decodable {
|
||||
let total: Int
|
||||
let list: [ReferralOrder]
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let saler: ReferralOrderSaler?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total
|
||||
case list
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
case saler
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
total = try container.decodeLossyInt(forKey: .total) ?? 0
|
||||
list = try container.decodeIfPresent([ReferralOrder].self, forKey: .list) ?? []
|
||||
page = try container.decodeLossyInt(forKey: .page) ?? 0
|
||||
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 0
|
||||
saler = try container.decodeIfPresent(ReferralOrderSaler.self, forKey: .saler)
|
||||
}
|
||||
}
|
||||
|
||||
/// 可绑定带客单实体。
|
||||
struct ReferralOrder: Decodable, Identifiable {
|
||||
let id: Int
|
||||
let referralOrderNo: String
|
||||
let orderNo: String
|
||||
let userMobile: String
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
let images: [String]
|
||||
|
||||
var displayReferralOrderNo: String {
|
||||
referralOrderNo.cooperationNonEmptyOrDefault(orderNo)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case referralOrderNo = "referral_order_no"
|
||||
case orderNo = "order_no"
|
||||
case userMobile = "user_mobile"
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
case images
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
|
||||
orderNo = try container.decodeLossyString(forKey: .orderNo)
|
||||
userMobile = try container.decodeLossyString(forKey: .userMobile)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
|
||||
images = try container.decodeIfPresent([String].self, forKey: .images) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// listv2 已绑定订单来源 referral_order。
|
||||
struct BoundReferralOrder: Decodable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let orderNo: String
|
||||
let saleUserId: Int
|
||||
let salerName: String
|
||||
let salerPhone: String
|
||||
let displayText: String
|
||||
let userMobile: String
|
||||
let remark: String
|
||||
let bindOrderNumber: String
|
||||
let sourceType: Int
|
||||
let sourceTypeLabel: String
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderNo = "order_no"
|
||||
case saleUserId = "sale_user_id"
|
||||
case salerName = "saler_name"
|
||||
case salerPhone = "saler_phone"
|
||||
case displayText = "display_text"
|
||||
case userMobile = "user_mobile"
|
||||
case remark
|
||||
case bindOrderNumber = "bind_order_number"
|
||||
case sourceType = "source_type"
|
||||
case sourceTypeLabel = "source_type_label"
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
orderNo = try container.decodeLossyString(forKey: .orderNo)
|
||||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||||
salerName = try container.decodeLossyString(forKey: .salerName)
|
||||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||||
displayText = try container.decodeLossyString(forKey: .displayText)
|
||||
userMobile = try container.decodeLossyString(forKey: .userMobile)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
bindOrderNumber = try container.decodeLossyString(forKey: .bindOrderNumber)
|
||||
sourceType = try container.decodeLossyInt(forKey: .sourceType) ?? 0
|
||||
sourceTypeLabel = try container.decodeLossyString(forKey: .sourceTypeLabel)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫码绑定获客员 Sheet 标识。
|
||||
struct BindAcquirerSheetItem: Identifiable {
|
||||
let saleUserId: Int
|
||||
var id: Int { saleUserId }
|
||||
}
|
||||
|
||||
/// 合作订单模块内部导航路由。
|
||||
enum CooperationOrderRoute: Hashable {
|
||||
case acquirer
|
||||
case bind(saleUserId: Int)
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将任意常见 JSON 值宽松解码成字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double、Bool 或数字字符串宽松解码成整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Bool、Int 或字符串宽松解码成布尔值。
|
||||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value != 0
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
case "0", "false", "no":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
//
|
||||
// CooperationOrderFeature.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 合作订单权限特性,负责 URI 校验与访问判断。
|
||||
enum CooperationOrderFeature {
|
||||
static let uri = "cooperation_order"
|
||||
|
||||
/// 判断当前角色是否拥有合作订单权限。
|
||||
static func canAccess(permissionContext: PermissionContext) -> Bool {
|
||||
permissionContext.canAccess(uri)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
//
|
||||
// BindAcquirerViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// 绑定获客员 ViewModel,负责邀请信息加载与接受绑定。
|
||||
@MainActor
|
||||
final class BindAcquirerViewModel: ObservableObject {
|
||||
@Published var inviteInfo: SaleUserInviteInfo?
|
||||
@Published var isLoading = false
|
||||
@Published var isAlreadyBound = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var saleUserId = 0
|
||||
|
||||
/// 加载获客员邀请信息。
|
||||
func load(api: CooperationOrderServing, saleUserId: Int) async {
|
||||
guard saleUserId > 0 else {
|
||||
errorMessage = "获客员信息无效"
|
||||
return
|
||||
}
|
||||
if self.saleUserId == saleUserId && (inviteInfo != nil || isAlreadyBound) { return }
|
||||
self.saleUserId = saleUserId
|
||||
isAlreadyBound = false
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
var info = try await api.saleUserInviteInfo(saleUserId: saleUserId)
|
||||
if info.saleUserId <= 0 {
|
||||
info = SaleUserInviteInfo(
|
||||
saleUserId: saleUserId,
|
||||
isBound: info.isBound,
|
||||
photographerName: info.photographerName,
|
||||
photographerPhone: info.photographerPhone,
|
||||
salerName: info.salerName,
|
||||
salerPhone: info.salerPhone,
|
||||
shareCode: info.shareCode,
|
||||
subtitle: info.subtitle,
|
||||
title: info.title
|
||||
)
|
||||
}
|
||||
if info.isBound {
|
||||
isAlreadyBound = true
|
||||
}
|
||||
inviteInfo = info
|
||||
} catch {
|
||||
handleBindError(error.localizedDescription.isEmpty ? "获取邀请信息失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交接受绑定。
|
||||
func acceptBind(api: CooperationOrderServing) async throws {
|
||||
guard saleUserId > 0, !isAlreadyBound else { return }
|
||||
try await api.saleUserAcceptBind(saleUserId: saleUserId)
|
||||
}
|
||||
|
||||
/// 处理绑定错误,识别已绑定状态。
|
||||
func handleBindError(_ message: String) {
|
||||
errorMessage = message
|
||||
if message.contains("已绑定") {
|
||||
isAlreadyBound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
//
|
||||
// CooperationAcquirerViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// 合作获客员列表 ViewModel。
|
||||
@MainActor
|
||||
final class CooperationAcquirerViewModel: ObservableObject {
|
||||
@Published var acquirers: [CooperativeSaler] = []
|
||||
@Published private(set) var hasLoadedOnce = false
|
||||
|
||||
/// 重新加载当前门店下的合作获客员。
|
||||
func reload(api: CooperationOrderServing, storeId: Int?) async {
|
||||
do {
|
||||
let storeParam = storeId.flatMap { $0 > 0 ? $0 : nil }
|
||||
let data = try await api.cooperativeSalers(storeId: storeParam, page: 1, pageSize: 20)
|
||||
acquirers = data.list
|
||||
} catch {
|
||||
if !hasLoadedOnce {
|
||||
acquirers = []
|
||||
}
|
||||
}
|
||||
hasLoadedOnce = true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
//
|
||||
// CooperationOrderListViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// 合作订单列表 ViewModel,管理获客线索与获客订单双 Tab 分页。
|
||||
@MainActor
|
||||
final class CooperationOrderListViewModel: ObservableObject {
|
||||
@Published var selectedTab = 0
|
||||
@Published var searchText = ""
|
||||
@Published var leads: [ReferralLead] = []
|
||||
@Published var orders: [AcquisitionOrder] = []
|
||||
@Published var isLoadingMore = false
|
||||
@Published var canLoadMore = false
|
||||
@Published private(set) var hasLoadedLeadsOnce = false
|
||||
@Published private(set) var hasLoadedOrdersOnce = false
|
||||
|
||||
private var leadsPage = 1
|
||||
private var ordersPage = 1
|
||||
private var leadsTotal = 0
|
||||
private var ordersTotal = 0
|
||||
private var isReloading = false
|
||||
private let pageSize = 10
|
||||
|
||||
/// 切换 Tab 并触发对应列表刷新。
|
||||
func onTabSelected(_ index: Int) {
|
||||
guard selectedTab != index else { return }
|
||||
selectedTab = index
|
||||
}
|
||||
|
||||
/// 刷新当前 Tab 列表。
|
||||
func refreshCurrentTab(api: CooperationOrderServing) async {
|
||||
if selectedTab == 0 {
|
||||
await refreshLeads(api: api)
|
||||
} else {
|
||||
await refreshOrders(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新获客线索列表。
|
||||
func refreshLeads(api: CooperationOrderServing) async {
|
||||
guard !isReloading else { return }
|
||||
isReloading = true
|
||||
defer { isReloading = false }
|
||||
leadsPage = 1
|
||||
isLoadingMore = false
|
||||
await loadLeads(api: api)
|
||||
}
|
||||
|
||||
/// 刷新获客订单列表。
|
||||
func refreshOrders(api: CooperationOrderServing) async {
|
||||
guard !isReloading else { return }
|
||||
isReloading = true
|
||||
defer { isReloading = false }
|
||||
ordersPage = 1
|
||||
isLoadingMore = false
|
||||
await loadOrders(api: api)
|
||||
}
|
||||
|
||||
/// 加载更多当前 Tab 数据。
|
||||
func loadMore(api: CooperationOrderServing) async {
|
||||
guard !isReloading, !isLoadingMore, canLoadMore else { return }
|
||||
isLoadingMore = true
|
||||
if selectedTab == 0 {
|
||||
leadsPage += 1
|
||||
await loadLeads(api: api)
|
||||
} else {
|
||||
ordersPage += 1
|
||||
await loadOrders(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按关键词搜索获客订单。
|
||||
func searchOrders(api: CooperationOrderServing) async {
|
||||
guard selectedTab == 1 else { return }
|
||||
await refreshOrders(api: api)
|
||||
}
|
||||
|
||||
private func loadLeads(api: CooperationOrderServing) async {
|
||||
do {
|
||||
let data = try await api.saleUserReferralLeads(page: leadsPage, pageSize: pageSize)
|
||||
leadsTotal = data.total
|
||||
if leadsPage == 1 {
|
||||
leads = data.list
|
||||
} else {
|
||||
leads.append(contentsOf: data.list)
|
||||
}
|
||||
canLoadMore = leads.count < leadsTotal
|
||||
} catch {
|
||||
if leadsPage == 1, !hasLoadedLeadsOnce {
|
||||
leads = []
|
||||
}
|
||||
}
|
||||
if leadsPage == 1 {
|
||||
hasLoadedLeadsOnce = true
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
private func loadOrders(api: CooperationOrderServing) async {
|
||||
do {
|
||||
let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let data = try await api.saleUserAcquisitionOrders(
|
||||
keyword: keyword.isEmpty ? nil : keyword,
|
||||
page: ordersPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
ordersTotal = data.total
|
||||
if ordersPage == 1 {
|
||||
orders = data.list
|
||||
} else {
|
||||
orders.append(contentsOf: data.list)
|
||||
}
|
||||
canLoadMore = orders.count < ordersTotal
|
||||
} catch {
|
||||
if ordersPage == 1, !hasLoadedOrdersOnce {
|
||||
orders = []
|
||||
}
|
||||
}
|
||||
if ordersPage == 1 {
|
||||
hasLoadedOrdersOnce = true
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
141
suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift
Normal file
141
suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift
Normal file
@ -0,0 +1,141 @@
|
||||
//
|
||||
// BindAcquirerView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 绑定获客员确认页,展示邀请信息并提交接受绑定。
|
||||
struct BindAcquirerView: View {
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = BindAcquirerViewModel()
|
||||
let saleUserId: Int
|
||||
let onSuccess: () -> Void
|
||||
@State private var binding = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
if viewModel.isLoading && viewModel.inviteInfo == nil && !viewModel.isAlreadyBound {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
} else if let info = viewModel.inviteInfo {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(info.title.cooperationNonEmptyOrDefault("绑定获客员"))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
if !info.subtitle.isEmpty {
|
||||
Text(info.subtitle)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
infoCard(title: "获客员", value: info.salerName)
|
||||
infoCard(title: "手机号", value: info.salerPhone)
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await acceptBind() }
|
||||
} label: {
|
||||
Text(binding ? "提交中..." : (viewModel.isAlreadyBound ? "已绑定获客员" : "接受邀请"))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(
|
||||
viewModel.isAlreadyBound ? AppDesign.textSecondary : AppDesign.primary,
|
||||
in: RoundedRectangle(cornerRadius: 8)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(binding || viewModel.isAlreadyBound)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
} else if viewModel.isAlreadyBound {
|
||||
Spacer()
|
||||
Text("已绑定获客员")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
|
||||
Button {} label: {
|
||||
Text("已绑定获客员")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(AppDesign.textSecondary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
Text(error)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.inviteInfo?.title.cooperationNonEmptyOrDefault("接受邀请") ?? "接受邀请")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
.task {
|
||||
await viewModel.load(api: cooperationOrderAPI, saleUserId: saleUserId)
|
||||
if viewModel.errorMessage != nil && viewModel.inviteInfo == nil && !viewModel.isAlreadyBound {
|
||||
if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoCard(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value.isEmpty ? "--" : value)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func acceptBind() async {
|
||||
binding = true
|
||||
defer { binding = false }
|
||||
do {
|
||||
try await viewModel.acceptBind(api: cooperationOrderAPI)
|
||||
toastCenter.show("绑定成功")
|
||||
onSuccess()
|
||||
dismiss()
|
||||
} catch {
|
||||
viewModel.handleBindError(error.localizedDescription.isEmpty ? "绑定失败" : error.localizedDescription)
|
||||
if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
if !viewModel.isAlreadyBound {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
//
|
||||
// CooperationAcquirerView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 合作获客员列表页,支持查看已绑定获客员与扫码绑定。
|
||||
struct CooperationAcquirerView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.openURL) private var openURL
|
||||
@StateObject private var viewModel = CooperationAcquirerViewModel()
|
||||
@State private var showScanner = false
|
||||
let onBind: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if viewModel.hasLoadedOnce && viewModel.acquirers.isEmpty {
|
||||
Text("暂无合作获客员")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(CooperationOrderColors.text999)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
} else {
|
||||
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
|
||||
CooperativeSalerCard(acquirer: acquirer) { phone in
|
||||
dialPhone(phone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.navigationTitle("合作获客员")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("扫码绑定") {
|
||||
showScanner = true
|
||||
}
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(CooperationOrderColors.primary)
|
||||
}
|
||||
}
|
||||
.background(CooperationOrderColors.pageBg.ignoresSafeArea())
|
||||
.refreshable {
|
||||
await reload(showLoading: false)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showScanner) {
|
||||
OrderScannerPage(
|
||||
onClose: { showScanner = false },
|
||||
onSuccess: { raw in
|
||||
showScanner = false
|
||||
if let saleUserId = SaleUserQrParser.parseSaleUserId(raw) {
|
||||
onBind(saleUserId)
|
||||
} else {
|
||||
toastCenter.show("请扫描正确的获客员二维码")
|
||||
}
|
||||
},
|
||||
onFailure: { error in
|
||||
showScanner = false
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
)
|
||||
}
|
||||
.task {
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.reload(api: cooperationOrderAPI, storeId: accountContext.currentStore?.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func dialPhone(_ phone: String) {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
//
|
||||
// CooperationOrderAccessView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 合作订单权限入口,无权限时提示并返回。
|
||||
struct CooperationOrderAccessView: View {
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if CooperationOrderFeature.canAccess(permissionContext: permissionContext) {
|
||||
CooperationOrderListView()
|
||||
} else {
|
||||
Color.clear
|
||||
.onAppear {
|
||||
toastCenter.show("暂无合作订单权限")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))"
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
//
|
||||
// CooperationOrderListView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 合作订单主页,展示获客线索与获客订单双 Tab 列表。
|
||||
struct CooperationOrderListView: View {
|
||||
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@StateObject private var viewModel = CooperationOrderListViewModel()
|
||||
@State private var route: CooperationOrderRoute?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
CooperationOrderTabs(
|
||||
selectedTab: viewModel.selectedTab,
|
||||
onTabSelected: { viewModel.onTabSelected($0) }
|
||||
)
|
||||
|
||||
if viewModel.selectedTab == 1 {
|
||||
CooperationOrderSearchBar(searchText: $viewModel.searchText) {
|
||||
Task { await searchOrders(showLoading: true) }
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if viewModel.selectedTab == 0 {
|
||||
if viewModel.hasLoadedLeadsOnce && viewModel.leads.isEmpty {
|
||||
emptyState("暂无获客员线索")
|
||||
} else {
|
||||
ForEach(Array(viewModel.leads.enumerated()), id: \.element.id) { index, lead in
|
||||
ReferralLeadCard(lead: lead)
|
||||
.onAppear {
|
||||
guard index >= viewModel.leads.count - 3 else { return }
|
||||
Task { await viewModel.loadMore(api: cooperationOrderAPI) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if viewModel.hasLoadedOrdersOnce && viewModel.orders.isEmpty {
|
||||
emptyState("暂无相关合作订单")
|
||||
} else {
|
||||
ForEach(Array(viewModel.orders.enumerated()), id: \.element.id) { index, order in
|
||||
AcquisitionOrderCard(order: order)
|
||||
.onAppear {
|
||||
guard index >= viewModel.orders.count - 3 else { return }
|
||||
Task { await viewModel.loadMore(api: cooperationOrderAPI) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.tint(CooperationOrderColors.primary)
|
||||
.frame(width: 24, height: 24)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.refreshable {
|
||||
await reloadCurrentTab(showLoading: false)
|
||||
}
|
||||
}
|
||||
.navigationTitle("合作订单")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("合作获客员") {
|
||||
route = .acquirer
|
||||
}
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(CooperationOrderColors.primary)
|
||||
}
|
||||
}
|
||||
.background(CooperationOrderColors.pageBg.ignoresSafeArea())
|
||||
.appNavigationDestination(item: $route) { destination in
|
||||
switch destination {
|
||||
case .acquirer:
|
||||
CooperationAcquirerView(onBind: { saleUserId in
|
||||
route = .bind(saleUserId: saleUserId)
|
||||
})
|
||||
case let .bind(saleUserId):
|
||||
BindAcquirerView(saleUserId: saleUserId, onSuccess: {
|
||||
route = .acquirer
|
||||
})
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await reloadCurrentTab(showLoading: true)
|
||||
}
|
||||
.onChange(of: viewModel.selectedTab) { _ in
|
||||
Task { await reloadCurrentTab(showLoading: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadCurrentTab(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.refreshCurrentTab(api: cooperationOrderAPI)
|
||||
}
|
||||
}
|
||||
|
||||
private func searchOrders(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.searchOrders(api: cooperationOrderAPI)
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyState(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(CooperationOrderColors.text999)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
}
|
||||
}
|
||||
@ -9,17 +9,21 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
||||
## 菜单生成
|
||||
|
||||
`HomeViewModel` 从当前角色的权限树递归提取菜单:
|
||||
- 当前角色 ID 存在但找不到时清空菜单。
|
||||
- 当前角色 ID 为空时使用第一个角色。
|
||||
- 当前 `role_code` 存在但找不到时清空菜单。
|
||||
- 当前 `role_code` 为空时使用第一个角色。
|
||||
- URI 按旧工程顺序排序。
|
||||
- 同义 URI 按 `HomeMenuRouter.menuAliasKey` 去重。
|
||||
|
||||
角色 UI 判断使用 `PermissionContext.currentAppRole`(`AppRoleCode` 枚举),例如店长 `isStoreAdmin`、首页隐藏工作状态 `homeMinimalTopRoles`。
|
||||
|
||||
## 常用应用
|
||||
|
||||
`HomeCommonMenuStore` 使用 UserDefaults 保存常用应用 URI:
|
||||
- 首次无配置时写入默认常用应用。
|
||||
`HomeCommonMenuStore` 使用 UserDefaults 按角色保存常用应用 URI(key:`home.common.menu.uris.role.{roleCode}`):
|
||||
- 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。
|
||||
- 读取时按当前角色权限过滤不可用 URI。
|
||||
- 添加和移除时按同义 URI 去重。
|
||||
- 不同角色的常用应用互不影响。
|
||||
- 升级时会从旧版 `role.{roleId}` key 一次性迁移到 `role.{roleCode}`。
|
||||
|
||||
## 路由规则
|
||||
|
||||
@ -33,6 +37,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
||||
|
||||
## 后续迁移
|
||||
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`cooperation_order` 已由 `Features/CooperationOrder` 接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
|
||||
|
||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||
|
||||
@ -56,6 +56,7 @@ enum HomeRoute: Hashable {
|
||||
case locationReportHistory
|
||||
case depositOrders
|
||||
case withdrawalAudit
|
||||
case cooperationOrder
|
||||
case scenicSettlement
|
||||
case scenicSettlementReview
|
||||
case messageCenter
|
||||
|
||||
@ -60,6 +60,7 @@ enum HomeMenuRouter {
|
||||
"deposit_order": "押金订单详情",
|
||||
"deposit_order_shooting_info": "押金拍摄信息",
|
||||
"withdrawal_audit": "提现审核",
|
||||
"cooperation_order": "合作订单",
|
||||
"location_report_history": "定位上报历史",
|
||||
"photographer_invite": "邀请摄影师",
|
||||
"invite_record": "邀请记录",
|
||||
@ -131,6 +132,8 @@ enum HomeMenuRouter {
|
||||
return .destination(.depositOrders)
|
||||
case "withdrawal_audit":
|
||||
return .destination(.withdrawalAudit)
|
||||
case "cooperation_order":
|
||||
return .destination(.cooperationOrder)
|
||||
case "scenic_settlement":
|
||||
return .destination(.scenicSettlement)
|
||||
case "scenic_settlement_review":
|
||||
@ -199,6 +202,7 @@ enum HomeMenuRouter {
|
||||
"scenic_settlement",
|
||||
"scenic_settlement_review",
|
||||
"verification_order",
|
||||
"cooperation_order",
|
||||
"photographer_orders",
|
||||
"/scenic-order-manage",
|
||||
"photographer_stats",
|
||||
@ -301,10 +305,15 @@ enum HomeMenuRouter {
|
||||
/// 首页权限路由审计器,用于识别权限里哪些 URI 尚未映射。
|
||||
enum HomePermissionRouteAuditor {
|
||||
/// 扫描当前角色权限并分类统计可路由、已知不支持和未知 URI。
|
||||
static func audit(permissions: [RolePermissionResponse], currentRoleId: Int?) -> HomePermissionRouteAudit {
|
||||
static func audit(permissions: [RolePermissionResponse], currentRoleCode: String?) -> HomePermissionRouteAudit {
|
||||
let source: RolePermissionResponse?
|
||||
if let currentRoleId {
|
||||
source = permissions.first { $0.role.id == currentRoleId }
|
||||
if let currentRoleCode, !currentRoleCode.isEmpty {
|
||||
source = RolePermissionMatching.match(
|
||||
in: permissions,
|
||||
roleCode: currentRoleCode,
|
||||
roleId: nil,
|
||||
roleName: nil
|
||||
)
|
||||
} else {
|
||||
source = permissions.first
|
||||
}
|
||||
|
||||
@ -7,47 +7,71 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页常用应用存储服务,负责按当前权限过滤和持久化常用 URI。
|
||||
/// 首页常用应用存储服务,负责按当前角色权限过滤和持久化常用 URI。
|
||||
struct HomeCommonMenuStore {
|
||||
static let defaultStorageKey = "home.common.menu.uris"
|
||||
static let defaultBaselineKey = "home.common.menu.android.baseline.v2"
|
||||
static let storageKeyPrefix = "home.common.menu.uris.role."
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey: String
|
||||
private let baselineKey: String
|
||||
|
||||
/// 初始化常用应用存储服务,并允许测试注入独立 UserDefaults。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
storageKey: String = Self.defaultStorageKey,
|
||||
baselineKey: String = Self.defaultBaselineKey
|
||||
) {
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.storageKey = storageKey
|
||||
self.baselineKey = baselineKey
|
||||
}
|
||||
|
||||
/// 读取常用 URI,首次使用时写入默认值,并始终按当前权限过滤。
|
||||
func load(menuItems: [HomeMenuItem]) -> [String] {
|
||||
/// 按 Android 规则生成默认常用 URI:顶层权限顺序、可用权限过滤、别名去重,超过 4 个取前 4 个。
|
||||
static func defaultCommonURIs(
|
||||
orderedTopLevelURIs: [String],
|
||||
availableURIs: Set<String>
|
||||
) -> [String] {
|
||||
let resolved = orderedTopLevelURIs.compactMap { uri -> String? in
|
||||
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let canonical = HomeMenuRouter.canonicalURI(for: trimmed, availableURIs: availableURIs)
|
||||
return availableURIs.contains(canonical) ? canonical : nil
|
||||
}
|
||||
let deduplicated = unique(resolved)
|
||||
if deduplicated.count > 4 {
|
||||
return Array(deduplicated.prefix(4))
|
||||
}
|
||||
return deduplicated
|
||||
}
|
||||
|
||||
/// 读取常用 URI;无角色时返回空,无保存或过滤后为空时写入 Android 对齐默认值。
|
||||
func load(
|
||||
menuItems: [HomeMenuItem],
|
||||
roleCode: String?,
|
||||
legacyRoleId: Int? = nil,
|
||||
topLevelPermissionURIs: [String]
|
||||
) -> [String] {
|
||||
guard let roleCode, !roleCode.isEmpty else { return [] }
|
||||
migrateLegacyStorageIfNeeded(roleCode: roleCode, legacyRoleId: legacyRoleId)
|
||||
|
||||
let availableURIs = Set(menuItems.map(\.uri))
|
||||
if !defaults.bool(forKey: baselineKey) {
|
||||
let defaults = defaultCommonURIs(availableURIs: availableURIs)
|
||||
save(defaults)
|
||||
self.defaults.set(true, forKey: baselineKey)
|
||||
return defaults
|
||||
let key = storageKey(for: roleCode)
|
||||
let saved = defaults.stringArray(forKey: key)
|
||||
|
||||
if let saved, !saved.isEmpty {
|
||||
let filtered = Self.unique(saved.compactMap { uri -> String? in
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
return availableURIs.contains(resolved) ? resolved : nil
|
||||
})
|
||||
if !filtered.isEmpty {
|
||||
save(filtered, roleCode: roleCode)
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
let saved = defaults.stringArray(forKey: storageKey) ?? []
|
||||
let filtered = unique(saved.compactMap { uri -> String? in
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
return availableURIs.contains(resolved) ? resolved : nil
|
||||
})
|
||||
save(filtered)
|
||||
return filtered
|
||||
let defaultURIs = Self.defaultCommonURIs(
|
||||
orderedTopLevelURIs: topLevelPermissionURIs,
|
||||
availableURIs: availableURIs
|
||||
)
|
||||
save(defaultURIs, roleCode: roleCode)
|
||||
return defaultURIs
|
||||
}
|
||||
|
||||
/// 将指定 URI 加入常用应用,按别名避免重复添加。
|
||||
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] {
|
||||
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?) -> [String] {
|
||||
guard let roleCode, !roleCode.isEmpty else { return current }
|
||||
let availableURIs = Set(menuItems.map(\.uri))
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
guard availableURIs.contains(resolved) else { return current }
|
||||
@ -55,34 +79,49 @@ struct HomeCommonMenuStore {
|
||||
return current
|
||||
}
|
||||
let next = current + [resolved]
|
||||
save(next)
|
||||
save(next, roleCode: roleCode)
|
||||
return next
|
||||
}
|
||||
|
||||
/// 从常用应用中移除指定 URI,同义 URI 会一起移除。
|
||||
func remove(_ uri: String, current: [String]) -> [String] {
|
||||
func remove(_ uri: String, current: [String], roleCode: String?) -> [String] {
|
||||
guard let roleCode, !roleCode.isEmpty else { return current }
|
||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
|
||||
save(next)
|
||||
save(next, roleCode: roleCode)
|
||||
return next
|
||||
}
|
||||
|
||||
/// 保存常用 URI。
|
||||
func save(_ uris: [String]) {
|
||||
defaults.set(unique(uris), forKey: storageKey)
|
||||
/// 保存指定角色的常用 URI。
|
||||
func save(_ uris: [String], roleCode: String) {
|
||||
defaults.set(Self.unique(uris), forKey: storageKey(for: roleCode))
|
||||
}
|
||||
|
||||
/// 生成默认常用应用 URI。
|
||||
private func defaultCommonURIs(availableURIs: Set<String>) -> [String] {
|
||||
let preferred = ["registration_invitation", "location_report", "pm_manager", "pm"]
|
||||
return unique(preferred.compactMap { uri in
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
return availableURIs.contains(resolved) ? resolved : nil
|
||||
})
|
||||
/// 若新 key 无数据,则从 legacy role id key 一次性迁移常用菜单。
|
||||
func migrateLegacyStorageIfNeeded(roleCode: String, legacyRoleId: Int?) {
|
||||
let newKey = storageKey(for: roleCode)
|
||||
guard defaults.stringArray(forKey: newKey) == nil,
|
||||
let legacyRoleId,
|
||||
let legacyData = defaults.stringArray(forKey: legacyStorageKey(for: legacyRoleId)),
|
||||
!legacyData.isEmpty else {
|
||||
return
|
||||
}
|
||||
defaults.set(legacyData, forKey: newKey)
|
||||
defaults.removeObject(forKey: legacyStorageKey(for: legacyRoleId))
|
||||
}
|
||||
|
||||
/// 生成按 role_code 隔离的 UserDefaults key。
|
||||
private func storageKey(for roleCode: String) -> String {
|
||||
Self.storageKeyPrefix + roleCode
|
||||
}
|
||||
|
||||
/// 旧版按 numeric role id 存储的 key。
|
||||
private func legacyStorageKey(for roleId: Int) -> String {
|
||||
Self.storageKeyPrefix + String(roleId)
|
||||
}
|
||||
|
||||
/// 按 URI 别名去重,保留首次出现的 URI。
|
||||
private func unique(_ uris: [String]) -> [String] {
|
||||
private static func unique(_ uris: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return uris.filter { uri in
|
||||
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted
|
||||
|
||||
@ -58,10 +58,15 @@ final class HomeViewModel: ObservableObject {
|
||||
]
|
||||
|
||||
/// 从角色权限树扁平化出菜单项,按固定顺序排序并按 URI 别名去重。
|
||||
func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) {
|
||||
func buildMenus(from permissions: [RolePermissionResponse], currentRoleCode: String?) {
|
||||
let source: RolePermissionResponse?
|
||||
if let currentRoleId {
|
||||
source = permissions.first { $0.role.id == currentRoleId }
|
||||
if let currentRoleCode, !currentRoleCode.isEmpty {
|
||||
source = RolePermissionMatching.match(
|
||||
in: permissions,
|
||||
roleCode: currentRoleCode,
|
||||
roleId: nil,
|
||||
roleName: nil
|
||||
)
|
||||
} else {
|
||||
source = permissions.first
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ struct HomeMoreFunctionsView: View {
|
||||
.task {
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _ in
|
||||
@ -122,10 +122,16 @@ struct HomeMoreFunctionsView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||
if isCommon {
|
||||
commonUris = commonMenuStore.remove(item.uri, current: commonUris)
|
||||
commonUris = commonMenuStore.remove(item.uri, current: commonUris, roleCode: roleCode)
|
||||
} else {
|
||||
commonUris = commonMenuStore.add(item.uri, current: commonUris, menuItems: viewModel.menuItems)
|
||||
commonUris = commonMenuStore.add(
|
||||
item.uri,
|
||||
current: commonUris,
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill")
|
||||
@ -195,10 +201,17 @@ struct HomeMoreFunctionsView: View {
|
||||
}
|
||||
|
||||
private func rebuildMenus() {
|
||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||
let legacyRoleId = permissionContext.currentRole?.id
|
||||
viewModel.buildMenus(
|
||||
from: permissionContext.rolePermissions,
|
||||
currentRoleId: permissionContext.currentRole?.id
|
||||
currentRoleCode: roleCode
|
||||
)
|
||||
commonUris = commonMenuStore.load(
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode,
|
||||
legacyRoleId: legacyRoleId,
|
||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||
)
|
||||
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,6 +88,8 @@ extension HomeRoute {
|
||||
DepositOrderEntryView()
|
||||
case .withdrawalAudit:
|
||||
WithdrawalAuditView()
|
||||
case .cooperationOrder:
|
||||
CooperationOrderAccessView()
|
||||
case .scenicSettlement:
|
||||
ScenicSettlementView()
|
||||
case .scenicSettlementReview:
|
||||
|
||||
@ -27,23 +27,18 @@ struct HomeView: View {
|
||||
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
private let minimalTopRoleIds: Set<Int> = [46, 47, 52, 53, 54]
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 760 : .infinity
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
permissionContext.currentRole?.id
|
||||
}
|
||||
|
||||
private var isStoreManager: Bool {
|
||||
currentRoleId == 46
|
||||
permissionContext.currentAppRole?.isStoreAdmin == true
|
||||
}
|
||||
|
||||
private var shouldShowWorkStatus: Bool {
|
||||
guard let currentRoleId else { return true }
|
||||
return !minimalTopRoleIds.contains(currentRoleId)
|
||||
guard let role = permissionContext.currentAppRole else { return true }
|
||||
return !AppRoleCode.homeMinimalTopRoles.contains(role)
|
||||
}
|
||||
|
||||
private var currentScenicName: String {
|
||||
@ -66,9 +61,8 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var displayMenuItems: [HomeMenuItem] {
|
||||
let selected = commonUris.compactMap(menuItemForHomeEntry(uri:))
|
||||
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
|
||||
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||
let items = commonUris.compactMap(menuItemForHomeEntry(uri:))
|
||||
return items + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -113,7 +107,7 @@ struct HomeView: View {
|
||||
guard isOnline, secondsUntilReport > 0 else { return }
|
||||
secondsUntilReport -= 1
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _ in
|
||||
@ -412,11 +406,18 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private func rebuildMenusFromCurrentContext() {
|
||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||
let legacyRoleId = permissionContext.currentRole?.id
|
||||
viewModel.buildMenus(
|
||||
from: permissionContext.rolePermissions,
|
||||
currentRoleId: permissionContext.currentRole?.id
|
||||
currentRoleCode: roleCode
|
||||
)
|
||||
commonUris = commonMenuStore.load(
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode,
|
||||
legacyRoleId: legacyRoleId,
|
||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||
)
|
||||
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@ struct MainTabsView: View {
|
||||
|
||||
@StateObject private var badgeViewModel = MainTabBadgeViewModel()
|
||||
@State private var showScanner = false
|
||||
@State private var bindAcquirerSaleUserId: Int?
|
||||
@State private var tabSelection: MainTabSelection = .tab(.home)
|
||||
@State private var selectedBusinessTab: AppTab = .home
|
||||
|
||||
@ -52,7 +53,7 @@ struct MainTabsView: View {
|
||||
onClose: { showScanner = false },
|
||||
onSuccess: { rawCode in
|
||||
showScanner = false
|
||||
appRouter.routeToOrderVerification(scannedCode: rawCode)
|
||||
handleGlobalScanResult(rawCode)
|
||||
},
|
||||
onFailure: { error in
|
||||
showScanner = false
|
||||
@ -60,6 +61,13 @@ struct MainTabsView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.fullScreenCover(item: bindAcquirerBinding) { item in
|
||||
NavigationStack {
|
||||
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
|
||||
bindAcquirerSaleUserId = nil
|
||||
})
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
syncSelection(with: appRouter.selectedTab)
|
||||
}
|
||||
@ -91,6 +99,26 @@ struct MainTabsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// 处理主 Tab 扫码结果,获客员绑定二维码优先弹出绑定页。
|
||||
private func handleGlobalScanResult(_ rawCode: String) {
|
||||
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
|
||||
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
|
||||
toastCenter.show("请扫描正确的获客员二维码")
|
||||
return
|
||||
}
|
||||
bindAcquirerSaleUserId = saleUserId
|
||||
return
|
||||
}
|
||||
appRouter.routeToOrderVerification(scannedCode: rawCode)
|
||||
}
|
||||
|
||||
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
|
||||
Binding(
|
||||
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
|
||||
set: { bindAcquirerSaleUserId = $0?.saleUserId }
|
||||
)
|
||||
}
|
||||
|
||||
/// 处理系统 TabBar 选择变化,扫码入口只弹出页面并立即恢复之前的业务 Tab 选中态。
|
||||
private func handleTabSelection(_ selection: MainTabSelection) {
|
||||
switch selection {
|
||||
|
||||
@ -55,12 +55,9 @@ final class OperatingAreaViewModel: ObservableObject {
|
||||
/// 根据当前上下文解析运营区域入口模式。
|
||||
@discardableResult
|
||||
func resolveMode(accountContext: AccountContext, permissionContext: PermissionContext) -> OperatingAreaEntryMode? {
|
||||
let roleName = permissionContext.currentRole?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lowercaseRoleName = roleName.lowercased()
|
||||
let isStoreRole = roleName.contains("店铺") || roleName.contains("门店") || lowercaseRoleName.contains("store")
|
||||
let isScenicRole = roleName.contains("景区") || lowercaseRoleName.contains("scenic")
|
||||
let appRole = permissionContext.currentAppRole
|
||||
|
||||
if isStoreRole {
|
||||
if appRole?.isStoreAdmin == true {
|
||||
guard let storeId = accountContext.currentStore?.id, storeId > 0 else {
|
||||
setBlocked(.missingStore)
|
||||
return nil
|
||||
@ -70,7 +67,7 @@ final class OperatingAreaViewModel: ObservableObject {
|
||||
return mode
|
||||
}
|
||||
|
||||
if isScenicRole {
|
||||
if appRole?.isScenicAdmin == true {
|
||||
guard let scenicId = accountContext.currentScenic?.id, scenicId > 0 else {
|
||||
setBlocked(.missingScenic)
|
||||
return nil
|
||||
|
||||
@ -42,7 +42,7 @@ struct OperatingAreaView: View {
|
||||
}
|
||||
|
||||
private var taskID: String {
|
||||
"\(permissionContext.currentRole?.id ?? 0)-\(accountContext.currentScenic?.id ?? 0)-\(accountContext.currentStore?.id ?? 0)"
|
||||
"\(permissionContext.currentAppRole?.rawValue ?? "")-\(accountContext.currentScenic?.id ?? 0)-\(accountContext.currentStore?.id ?? 0)"
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
|
||||
@ -61,6 +61,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
let photoTravel: OrderPhotoTravel?
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
let canBindReferral: Int
|
||||
let showOrderSource: Int
|
||||
let referralOrderNo: String
|
||||
let referralSalerName: String
|
||||
let referralOrder: BoundReferralOrder?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case photogUid = "photog_uid"
|
||||
@ -89,6 +94,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
case photoTravel = "photo_travel"
|
||||
case isNeedEdit
|
||||
case isRefined = "is_refined"
|
||||
case canBindReferral = "can_bind_referral"
|
||||
case showOrderSource = "show_order_source"
|
||||
case referralOrderNo = "referral_order_no"
|
||||
case referralSalerName = "referral_saler_name"
|
||||
case referralOrder = "referral_order"
|
||||
}
|
||||
|
||||
/// 宽松解码订单字段,兼容后端数字和字符串混用。
|
||||
@ -120,6 +130,11 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
||||
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
|
||||
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
|
||||
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
|
||||
canBindReferral = try container.decodeLossyInt(forKey: .canBindReferral) ?? 0
|
||||
showOrderSource = try container.decodeLossyInt(forKey: .showOrderSource) ?? 0
|
||||
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
|
||||
referralSalerName = try container.decodeLossyString(forKey: .referralSalerName)
|
||||
referralOrder = try container.decodeIfPresent(BoundReferralOrder.self, forKey: .referralOrder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
106
suixinkan/Features/Orders/Models/OrderSourceModels.swift
Normal file
106
suixinkan/Features/Orders/Models/OrderSourceModels.swift
Normal file
@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
struct OrderSourcePerson: Identifiable, Hashable {
|
||||
let key: String
|
||||
let name: String
|
||||
let phone: String
|
||||
|
||||
var id: String { key }
|
||||
}
|
||||
|
||||
struct OrderSourceLead: Identifiable, Hashable {
|
||||
let key: String
|
||||
let userPhone: String
|
||||
let remark: String
|
||||
let images: [String]
|
||||
|
||||
var id: String { key }
|
||||
}
|
||||
|
||||
struct OrderSourceSelection: Hashable {
|
||||
let person: OrderSourcePerson
|
||||
let lead: OrderSourceLead
|
||||
}
|
||||
|
||||
extension CooperativeSaler {
|
||||
func toOrderSourcePerson() -> OrderSourcePerson {
|
||||
OrderSourcePerson(
|
||||
key: "\(displayId)",
|
||||
name: displayName,
|
||||
phone: displayPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ReferralOrder {
|
||||
func toOrderSourceLead() -> OrderSourceLead {
|
||||
let remarkText: String
|
||||
if !remark.isEmpty {
|
||||
remarkText = remark
|
||||
} else if !statusLabel.isEmpty {
|
||||
remarkText = statusLabel
|
||||
} else {
|
||||
remarkText = "带客单 \(displayReferralOrderNo)"
|
||||
}
|
||||
return OrderSourceLead(
|
||||
key: displayReferralOrderNo,
|
||||
userPhone: userMobile,
|
||||
remark: remarkText,
|
||||
images: images
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension BoundReferralOrder {
|
||||
func toOrderSourceSelection(fallbackSalerName: String = "") -> OrderSourceSelection? {
|
||||
let referralNo = orderNo.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !referralNo.isEmpty else { return nil }
|
||||
let personName = salerName.isEmpty
|
||||
? (fallbackSalerName.isEmpty ? "—" : fallbackSalerName)
|
||||
: salerName
|
||||
let remarkText: String
|
||||
if !remark.isEmpty {
|
||||
remarkText = remark
|
||||
} else if !displayText.isEmpty {
|
||||
remarkText = displayText
|
||||
} else {
|
||||
remarkText = "带客单号 \(referralNo)"
|
||||
}
|
||||
return OrderSourceSelection(
|
||||
person: OrderSourcePerson(
|
||||
key: saleUserId > 0 ? "\(saleUserId)" : "",
|
||||
name: personName,
|
||||
phone: salerPhone
|
||||
),
|
||||
lead: OrderSourceLead(
|
||||
key: referralNo,
|
||||
userPhone: userMobile,
|
||||
remark: remarkText,
|
||||
images: []
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension OrderEntity {
|
||||
func toBoundOrderSourceSelection() -> OrderSourceSelection? {
|
||||
if let referralOrder,
|
||||
let selection = referralOrder.toOrderSourceSelection(fallbackSalerName: referralSalerName) {
|
||||
return selection
|
||||
}
|
||||
guard !referralOrderNo.isEmpty else { return nil }
|
||||
return OrderSourceSelection(
|
||||
person: OrderSourcePerson(
|
||||
key: "",
|
||||
name: referralSalerName.isEmpty ? "—" : referralSalerName,
|
||||
phone: ""
|
||||
),
|
||||
lead: OrderSourceLead(
|
||||
key: referralOrderNo,
|
||||
userPhone: phone,
|
||||
remark: "带客单号 \(referralOrderNo)",
|
||||
images: []
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传和尾片入口闭环。
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传、尾片入口,以及 orderType 11/14 的订单来源(带客单绑定)闭环。
|
||||
|
||||
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。
|
||||
|
||||
@ -24,7 +24,7 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
|
||||
|
||||
## 数据流程
|
||||
|
||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentRole` 读取角色 ID。景区管理员角色 `roleId == 53` 使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentAppRole` 读取当前角色。景区管理员角色(`AppRoleCode.scenicAdmin`)使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
||||
|
||||
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。
|
||||
|
||||
@ -42,6 +42,8 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
|
||||
|
||||
视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。
|
||||
|
||||
订单来源(带客单绑定)仅对 `orderType == 11`(摄影师跟拍)或 `14`(线下扫码)展示,景区管理员只读不可操作。订单卡片嵌入 `OrderSourcePicker` Sheet:选择合作获客员 → 加载其可绑定带客单 → 确认绑定。全页流程使用 `ReferralOrderSelectView`。绑定接口为 `POST .../sale-user/bind-referral-order`;普通订单退款成功后若存在 `referralOrder.id`,异步调用 `POST .../sale-user/referral-orders/{id}/status`(status=3)。获客员与带客单数据由 `CooperationOrderAPI` 提供。
|
||||
|
||||
## 路由边界
|
||||
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。
|
||||
|
||||
@ -31,6 +31,16 @@ final class OrdersViewModel: ObservableObject {
|
||||
@Published private(set) var writeOffPage = 1
|
||||
@Published private(set) var writeOffHasMore = false
|
||||
|
||||
@Published var orderSourcePeople: [OrderSourcePerson] = []
|
||||
@Published var orderSourceLeads: [String: [OrderSourceLead]] = [:]
|
||||
@Published var orderSourceSelections: [String: OrderSourceSelection] = [:]
|
||||
@Published var loadingOrderSourceLeadsFor: String?
|
||||
|
||||
private var loadingOrderSourcePeople = false
|
||||
|
||||
static let orderTypePhotographerFollow = 11
|
||||
static let orderTypePhotographerFollowOfflineScan = 14
|
||||
|
||||
/// 返回去除首尾空白后的手机号搜索值。
|
||||
var normalizedPhone: String? {
|
||||
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@ -38,17 +48,17 @@ final class OrdersViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
/// 根据当前子入口刷新对应列表。
|
||||
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, appRole: AppRoleCode?, showLoading: Bool = true) async throws {
|
||||
switch selectedEntry {
|
||||
case .storeOrders:
|
||||
try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading)
|
||||
try await reloadStoreOrders(api: api, scenicId: scenicId, appRole: appRole, showLoading: showLoading)
|
||||
case .verificationOrders:
|
||||
try await reloadWriteOffOrders(api: api, scenicId: scenicId, storeId: storeId, showLoading: showLoading)
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新订单管理列表,缺少景区时清空旧数据。
|
||||
func reloadStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
func reloadStoreOrders(api: OrderServing, scenicId: Int?, appRole: AppRoleCode?, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
storeOrders = []
|
||||
storeTotal = 0
|
||||
@ -69,16 +79,17 @@ final class OrdersViewModel: ObservableObject {
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
isScenicAdmin: appRole?.isScenicAdmin == true
|
||||
)
|
||||
storeOrders = result.list
|
||||
storeTotal = result.total
|
||||
storePage = 1
|
||||
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
|
||||
syncOrderSourceSelections(from: result.list)
|
||||
}
|
||||
|
||||
/// 加载订单管理下一页。
|
||||
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, appRole: AppRoleCode?) async throws {
|
||||
guard !loadingMore, storeHasMore, let scenicId else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
@ -93,7 +104,7 @@ final class OrdersViewModel: ObservableObject {
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
isScenicAdmin: appRole?.isScenicAdmin == true
|
||||
)
|
||||
storeOrders.append(contentsOf: result.list)
|
||||
storeTotal = result.total
|
||||
@ -154,6 +165,81 @@ final class OrdersViewModel: ObservableObject {
|
||||
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
|
||||
}
|
||||
|
||||
/// 判断订单卡片是否展示订单来源入口。
|
||||
func shouldShowOrderSource(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
|
||||
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
|
||||
return order.orderType == Self.orderTypePhotographerFollow
|
||||
|| order.orderType == Self.orderTypePhotographerFollowOfflineScan
|
||||
}
|
||||
|
||||
/// 景区管理员只读模式,不可操作订单来源。
|
||||
static func isScenicAdminReadOnly(appRole: AppRoleCode?) -> Bool {
|
||||
appRole?.isScenicAdmin == true
|
||||
}
|
||||
|
||||
/// 获取订单已选中的订单来源。
|
||||
func getOrderSourceSelection(for order: OrderEntity) -> OrderSourceSelection? {
|
||||
orderSourceSelections[order.orderNumber] ?? order.toBoundOrderSourceSelection()
|
||||
}
|
||||
|
||||
/// 从列表数据同步已绑定订单来源。
|
||||
func syncOrderSourceSelections(from orders: [OrderEntity]) {
|
||||
var updated = orderSourceSelections
|
||||
for order in orders {
|
||||
if let selection = order.toBoundOrderSourceSelection() {
|
||||
updated[order.orderNumber] = selection
|
||||
}
|
||||
}
|
||||
orderSourceSelections = updated
|
||||
}
|
||||
|
||||
/// 打开订单来源 Sheet 前预加载获客员列表。
|
||||
func prepareOrderSourceSheet(api: CooperationOrderServing, storeId: Int?) async {
|
||||
orderSourceLeads = [:]
|
||||
await loadOrderSourcePeople(api: api, storeId: storeId)
|
||||
}
|
||||
|
||||
/// 加载合作获客员列表供订单来源选择。
|
||||
func loadOrderSourcePeople(api: CooperationOrderServing, storeId: Int?) async {
|
||||
if loadingOrderSourcePeople { return }
|
||||
loadingOrderSourcePeople = true
|
||||
defer { loadingOrderSourcePeople = false }
|
||||
do {
|
||||
let storeParam = storeId.flatMap { $0 > 0 ? $0 : nil }
|
||||
let data = try await api.cooperativeSalers(storeId: storeParam, page: 1, pageSize: 50)
|
||||
orderSourcePeople = data.list.map { $0.toOrderSourcePerson() }
|
||||
} catch {
|
||||
orderSourcePeople = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载指定获客员的可绑定带客单。
|
||||
func loadOrderSourceLeads(api: CooperationOrderServing, person: OrderSourcePerson) async {
|
||||
if loadingOrderSourceLeadsFor == person.key { return }
|
||||
loadingOrderSourceLeadsFor = person.key
|
||||
defer { loadingOrderSourceLeadsFor = nil }
|
||||
guard let saleUserId = Int(person.key) else { return }
|
||||
do {
|
||||
let data = try await api.saleUserReferralOrders(saleUserId: saleUserId, page: 1, pageSize: 50)
|
||||
orderSourceLeads[person.key] = data.list.map { $0.toOrderSourceLead() }
|
||||
} catch {
|
||||
orderSourceLeads[person.key] = []
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定订单来源带客单。
|
||||
func bindOrderSource(
|
||||
api: CooperationOrderServing,
|
||||
orderNumber: String,
|
||||
selection: OrderSourceSelection
|
||||
) async throws {
|
||||
try await api.saleUserBindReferralOrder(
|
||||
orderNumber: orderNumber,
|
||||
referralOrderNo: selection.lead.key
|
||||
)
|
||||
orderSourceSelections[orderNumber] = selection
|
||||
}
|
||||
|
||||
/// 解析扫码结果并返回当前核销列表中的匹配订单。
|
||||
func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? {
|
||||
guard let orderNumber = OrderNumberParser.parse(rawCode) else {
|
||||
|
||||
@ -11,6 +11,7 @@ import SwiftUI
|
||||
struct StoreOrderDetailView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@ -190,6 +191,11 @@ struct StoreOrderDetailView: View {
|
||||
private func submitRefund() async {
|
||||
let success = await refundViewModel.submit(api: ordersAPI, item: item)
|
||||
guard success else { return }
|
||||
if let referralOrderId = item.referralOrder?.id, referralOrderId > 0 {
|
||||
Task {
|
||||
try? await cooperationOrderAPI.saleUserUpdateReferralOrderStatus(id: referralOrderId, status: 3)
|
||||
}
|
||||
}
|
||||
showRefundSheet = false
|
||||
toastCenter.show("退款申请已提交")
|
||||
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id, forceReload: true)
|
||||
|
||||
365
suixinkan/Features/Orders/Views/OrderSourceComponents.swift
Normal file
365
suixinkan/Features/Orders/Views/OrderSourceComponents.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
227
suixinkan/Features/Orders/Views/OrderSourcePicker.swift
Normal file
227
suixinkan/Features/Orders/Views/OrderSourcePicker.swift
Normal file
@ -0,0 +1,227 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OrderSourcePicker: View {
|
||||
let selection: OrderSourceSelection?
|
||||
let people: [OrderSourcePerson]
|
||||
let leadsForPerson: (OrderSourcePerson) -> [OrderSourceLead]
|
||||
let leadsLoading: Bool
|
||||
let onSheetOpen: () -> Void
|
||||
let onPersonClick: (OrderSourcePerson) -> Void
|
||||
let onSelectionChange: (OrderSourceSelection) -> Void
|
||||
let callPhone: (String) -> Void
|
||||
|
||||
@State private var sheetVisible = false
|
||||
@State private var navigationPath = NavigationPath()
|
||||
@State private var previewImages: [String] = []
|
||||
@State private var previewIndex: Int?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("订单来源")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(OrderSourceColors.text563)
|
||||
if let selection {
|
||||
HStack(spacing: 10) {
|
||||
Text(selection.person.name)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(OrderSourceColors.text333)
|
||||
if !selection.person.phone.isEmpty {
|
||||
Text(selection.person.phone)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(OrderSourceColors.text666)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("选择获客员及其创建的带客单")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(OrderSourceColors.text666)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(OrderSourceColors.primary)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
|
||||
if let selection {
|
||||
Divider().overlay(OrderSourceColors.divider)
|
||||
SourceLeadContent(
|
||||
lead: selection.lead,
|
||||
compact: true,
|
||||
enablePhoneCall: false,
|
||||
onCall: callPhone,
|
||||
onImageTap: { images, index in
|
||||
previewImages = images
|
||||
previewIndex = index
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(OrderSourceColors.entryBg, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(OrderSourceColors.border, lineWidth: 1)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 8))
|
||||
.onTapGesture {
|
||||
openSheet()
|
||||
}
|
||||
.sheet(isPresented: $sheetVisible) {
|
||||
sheetContainer
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(item: previewBinding) { item in
|
||||
SimpleImagePreviewSheet(
|
||||
imageUrls: item.urls,
|
||||
startIndex: item.index,
|
||||
onDismiss: { previewIndex = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func openSheet() {
|
||||
onSheetOpen()
|
||||
navigationPath = NavigationPath()
|
||||
sheetVisible = true
|
||||
}
|
||||
|
||||
private var previewBinding: Binding<ImagePreviewItem?> {
|
||||
Binding(
|
||||
get: {
|
||||
guard let previewIndex else { return nil }
|
||||
return ImagePreviewItem(urls: previewImages, index: previewIndex)
|
||||
},
|
||||
set: { newValue in
|
||||
if newValue == nil { previewIndex = nil }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var sheetContainer: some View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
personSheetContent
|
||||
.navigationDestination(for: OrderSourcePerson.self) { person in
|
||||
leadSheetContent(person: person)
|
||||
}
|
||||
.navigationDestination(for: OrderSourceImagePreviewRoute.self) { route in
|
||||
SimpleImagePreviewScreen(
|
||||
imageUrls: route.urls,
|
||||
startIndex: route.startIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(Color.white)
|
||||
}
|
||||
|
||||
private var personSheetContent: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("选择人员后,再选择该人员创建的带客单")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(OrderSourceColors.text666)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
if people.isEmpty {
|
||||
Text("暂无合作获客员")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(OrderSourceColors.text666)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
} else {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(people) { person in
|
||||
SourcePersonRow(
|
||||
person: person,
|
||||
selected: selection?.person.key == person.key,
|
||||
onCall: callPhone,
|
||||
onTap: {
|
||||
onPersonClick(person)
|
||||
navigationPath.append(person)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.navigationTitle("选择获客员")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func leadSheetContent(person: OrderSourcePerson) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if !person.phone.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
Text(person.phone)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(OrderSourceColors.primary)
|
||||
.onTapGesture { callPhone(person.phone) }
|
||||
AcquirerCallButton(phone: person.phone, onCall: callPhone)
|
||||
}
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
if leadsLoading {
|
||||
ProgressView()
|
||||
.tint(OrderSourceColors.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 32)
|
||||
} else {
|
||||
let leads = leadsForPerson(person)
|
||||
if leads.isEmpty {
|
||||
Text("暂无可绑定带客单")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(OrderSourceColors.text666)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
} else {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(leads) { lead in
|
||||
SourceLeadCard(
|
||||
lead: lead,
|
||||
selected: selection?.person.key == person.key && selection?.lead.key == lead.key,
|
||||
onCall: callPhone,
|
||||
onImageTap: { images, index in
|
||||
navigationPath.append(
|
||||
OrderSourceImagePreviewRoute(urls: images, startIndex: index)
|
||||
)
|
||||
},
|
||||
onSelect: {
|
||||
onSelectionChange(OrderSourceSelection(person: person, lead: lead))
|
||||
sheetVisible = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.navigationTitle("\(person.name)创建的带客单")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ImagePreviewItem: Identifiable {
|
||||
let urls: [String]
|
||||
let index: Int
|
||||
var id: String { "\(index)-\(urls.joined())" }
|
||||
}
|
||||
|
||||
private struct OrderSourceImagePreviewRoute: Hashable {
|
||||
let urls: [String]
|
||||
let startIndex: Int
|
||||
}
|
||||
@ -14,9 +14,11 @@ struct OrdersView: View {
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@StateObject private var viewModel = OrdersViewModel()
|
||||
@State private var manualOrderNumber = ""
|
||||
@ -27,6 +29,8 @@ struct OrdersView: View {
|
||||
@State private var pendingVerifyOrderNumber: String?
|
||||
@State private var matchedScannedOrderNumber: String?
|
||||
@State private var scanHintMessage: String?
|
||||
@State private var bindAcquirerSaleUserId: Int?
|
||||
@State private var referralBindOrderNumber: String?
|
||||
|
||||
private var currentScenicId: Int? {
|
||||
accountContext.currentScenic?.id
|
||||
@ -36,8 +40,8 @@ struct OrdersView: View {
|
||||
accountContext.currentStore?.id
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
permissionContext.currentRole?.id
|
||||
private var currentAppRole: AppRoleCode? {
|
||||
permissionContext.currentAppRole
|
||||
}
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
@ -139,6 +143,32 @@ struct OrdersView: View {
|
||||
onApply: { Task { await reload() } }
|
||||
)
|
||||
}
|
||||
.fullScreenCover(item: bindAcquirerBinding) { item in
|
||||
NavigationStack {
|
||||
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
|
||||
bindAcquirerSaleUserId = nil
|
||||
})
|
||||
}
|
||||
}
|
||||
.appNavigationDestination(item: referralBindBinding) { item in
|
||||
ReferralOrderSelectView(orderNumber: item.orderNumber) {
|
||||
Task { await reload(showLoading: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
|
||||
Binding(
|
||||
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
|
||||
set: { bindAcquirerSaleUserId = $0?.saleUserId }
|
||||
)
|
||||
}
|
||||
|
||||
private var referralBindBinding: Binding<OrderNumberSheetItem?> {
|
||||
Binding(
|
||||
get: { referralBindOrderNumber.map(OrderNumberSheetItem.init(orderNumber:)) },
|
||||
set: { referralBindOrderNumber = $0?.orderNumber }
|
||||
)
|
||||
}
|
||||
|
||||
private var entrySection: some View {
|
||||
@ -413,6 +443,48 @@ struct OrdersView: View {
|
||||
orderInfoRow("付款方式", item.payTypeName.isEmpty ? "--" : item.payTypeName)
|
||||
orderInfoRow("手机号", maskPhoneNumber(item.phone))
|
||||
orderInfoRow("关联项目", item.projectName.isEmpty ? "--" : item.projectName)
|
||||
|
||||
if viewModel.shouldShowOrderSource(order: item, appRole: currentAppRole) {
|
||||
OrderSourcePicker(
|
||||
selection: viewModel.getOrderSourceSelection(for: item),
|
||||
people: viewModel.orderSourcePeople,
|
||||
leadsForPerson: { person in
|
||||
viewModel.orderSourceLeads[person.key] ?? []
|
||||
},
|
||||
leadsLoading: viewModel.loadingOrderSourceLeadsFor != nil,
|
||||
onSheetOpen: {
|
||||
Task {
|
||||
await viewModel.prepareOrderSourceSheet(
|
||||
api: cooperationOrderAPI,
|
||||
storeId: currentStoreId
|
||||
)
|
||||
}
|
||||
},
|
||||
onPersonClick: { person in
|
||||
Task {
|
||||
await viewModel.loadOrderSourceLeads(api: cooperationOrderAPI, person: person)
|
||||
}
|
||||
},
|
||||
onSelectionChange: { selection in
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.bindOrderSource(
|
||||
api: cooperationOrderAPI,
|
||||
orderNumber: item.orderNumber,
|
||||
selection: selection
|
||||
)
|
||||
toastCenter.show("绑定成功")
|
||||
await reload(showLoading: false)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
},
|
||||
callPhone: { phone in
|
||||
dialPhone(phone)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@ -522,7 +594,7 @@ struct OrdersView: View {
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
storeId: currentStoreId,
|
||||
roleId: currentRoleId,
|
||||
appRole: currentAppRole,
|
||||
showLoading: showLoading
|
||||
)
|
||||
}
|
||||
@ -544,7 +616,7 @@ struct OrdersView: View {
|
||||
|
||||
private func loadMoreStoreOrders() async {
|
||||
do {
|
||||
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, appRole: currentAppRole)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
@ -595,15 +667,30 @@ struct OrdersView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 消费主 TabBar 全局扫码结果,并复用订单页内部扫码核销处理。
|
||||
/// 消费主 TabBar 全局扫码结果,优先识别获客员绑定二维码。
|
||||
private func consumePendingScanCodeIfNeeded() async {
|
||||
guard let rawCode = appRouter.consumePendingOrderScanCode() else { return }
|
||||
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
|
||||
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
|
||||
toastCenter.show("请扫描正确的获客员二维码")
|
||||
return
|
||||
}
|
||||
bindAcquirerSaleUserId = saleUserId
|
||||
return
|
||||
}
|
||||
viewModel.selectedEntry = .verificationOrders
|
||||
appRouter.selectOrders(entry: .verificationOrders)
|
||||
await reload(showLoading: false)
|
||||
handleScanResult(rawCode)
|
||||
}
|
||||
|
||||
/// 拨打客户或获客员手机号。
|
||||
private func dialPhone(_ phone: String) {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||||
openURL(url)
|
||||
}
|
||||
|
||||
/// 处理扫码返回的原始内容,并进入二次确认核销流程。
|
||||
private func handleScanResult(_ rawCode: String) {
|
||||
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||
|
||||
312
suixinkan/Features/Orders/Views/ReferralOrderSelectView.swift
Normal file
312
suixinkan/Features/Orders/Views/ReferralOrderSelectView.swift
Normal file
@ -0,0 +1,312 @@
|
||||
//
|
||||
// ReferralOrderSelectView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// 带客单全页选择 ViewModel,分步选择获客员与带客单并确认绑定。
|
||||
@MainActor
|
||||
final class ReferralOrderSelectViewModel: ObservableObject {
|
||||
enum Step {
|
||||
case selectAcquirer
|
||||
case selectReferralOrder
|
||||
}
|
||||
|
||||
@Published var step: Step = .selectAcquirer
|
||||
@Published var acquirers: [CooperativeSaler] = []
|
||||
@Published var selectedAcquirer: CooperativeSaler?
|
||||
@Published var referralOrders: [ReferralOrder] = []
|
||||
@Published var selectedReferralOrder: ReferralOrder?
|
||||
@Published var isLoadingMore = false
|
||||
@Published var canLoadMore = false
|
||||
@Published private(set) var hasLoadedAcquirersOnce = false
|
||||
@Published private(set) var hasLoadedReferralOrdersOnce = false
|
||||
|
||||
private var orderNumber = ""
|
||||
private var currentPage = 1
|
||||
private var totalCount = 0
|
||||
private var isReloadingReferralOrders = false
|
||||
private let pageSize = 10
|
||||
|
||||
/// 初始化订单号并加载获客员列表。
|
||||
func initOrder(orderNumber: String, api: CooperationOrderServing, storeId: Int?) async {
|
||||
if self.orderNumber == orderNumber && !acquirers.isEmpty { return }
|
||||
self.orderNumber = orderNumber
|
||||
await loadAcquirers(api: api, storeId: storeId)
|
||||
}
|
||||
|
||||
private func loadAcquirers(api: CooperationOrderServing, storeId: Int?) async {
|
||||
do {
|
||||
let storeParam = storeId.flatMap { $0 > 0 ? $0 : nil }
|
||||
let data = try await api.cooperativeSalers(storeId: storeParam, page: 1, pageSize: 50)
|
||||
acquirers = data.list
|
||||
if data.list.count == 1 {
|
||||
await selectAcquirer(data.list[0], api: api)
|
||||
} else {
|
||||
step = .selectAcquirer
|
||||
}
|
||||
} catch {
|
||||
if !hasLoadedAcquirersOnce {
|
||||
acquirers = []
|
||||
}
|
||||
}
|
||||
hasLoadedAcquirersOnce = true
|
||||
}
|
||||
|
||||
/// 选择获客员并加载其带客单列表。
|
||||
func selectAcquirer(_ acquirer: CooperativeSaler, api: CooperationOrderServing) async {
|
||||
selectedAcquirer = acquirer
|
||||
selectedReferralOrder = nil
|
||||
hasLoadedReferralOrdersOnce = false
|
||||
step = .selectReferralOrder
|
||||
await refreshReferralOrders(api: api)
|
||||
}
|
||||
|
||||
/// 返回获客员选择步骤。
|
||||
func backToAcquirerStep() {
|
||||
if acquirers.count <= 1 { return }
|
||||
step = .selectAcquirer
|
||||
selectedReferralOrder = nil
|
||||
referralOrders = []
|
||||
}
|
||||
|
||||
/// 选中带客单。
|
||||
func selectReferralOrder(_ order: ReferralOrder) {
|
||||
selectedReferralOrder = order
|
||||
}
|
||||
|
||||
/// 刷新带客单列表。
|
||||
func refreshReferralOrders(api: CooperationOrderServing) async {
|
||||
guard let acquirer = selectedAcquirer else { return }
|
||||
guard !isReloadingReferralOrders else { return }
|
||||
isReloadingReferralOrders = true
|
||||
defer { isReloadingReferralOrders = false }
|
||||
currentPage = 1
|
||||
isLoadingMore = false
|
||||
await loadReferralOrders(api: api, saleUserId: acquirer.displayId)
|
||||
}
|
||||
|
||||
/// 加载更多带客单。
|
||||
func loadMoreReferralOrders(api: CooperationOrderServing) async {
|
||||
guard !isReloadingReferralOrders, !isLoadingMore, canLoadMore,
|
||||
let acquirer = selectedAcquirer else { return }
|
||||
isLoadingMore = true
|
||||
currentPage += 1
|
||||
await loadReferralOrders(api: api, saleUserId: acquirer.displayId)
|
||||
}
|
||||
|
||||
private func loadReferralOrders(api: CooperationOrderServing, saleUserId: Int) async {
|
||||
do {
|
||||
let data = try await api.saleUserReferralOrders(
|
||||
saleUserId: saleUserId,
|
||||
page: currentPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
totalCount = data.total
|
||||
if currentPage == 1 {
|
||||
referralOrders = data.list
|
||||
} else {
|
||||
referralOrders.append(contentsOf: data.list)
|
||||
}
|
||||
canLoadMore = referralOrders.count < totalCount
|
||||
} catch {
|
||||
if currentPage == 1, !hasLoadedReferralOrdersOnce {
|
||||
referralOrders = []
|
||||
}
|
||||
if currentPage > 1 {
|
||||
currentPage = max(currentPage - 1, 1)
|
||||
}
|
||||
}
|
||||
if currentPage == 1 {
|
||||
hasLoadedReferralOrdersOnce = true
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
/// 确认绑定选中的带客单。
|
||||
func confirmBind(api: CooperationOrderServing) async throws {
|
||||
guard let referralOrder = selectedReferralOrder else {
|
||||
throw NSError(domain: "ReferralOrder", code: 1, userInfo: [NSLocalizedDescriptionKey: "请选择带客单"])
|
||||
}
|
||||
guard !orderNumber.isEmpty else {
|
||||
throw NSError(domain: "ReferralOrder", code: 2, userInfo: [NSLocalizedDescriptionKey: "订单号无效"])
|
||||
}
|
||||
try await api.saleUserBindReferralOrder(
|
||||
orderNumber: orderNumber,
|
||||
referralOrderNo: referralOrder.displayReferralOrderNo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 带客单全页选择视图,用于订单管理绑定订单来源。
|
||||
struct ReferralOrderSelectView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = ReferralOrderSelectViewModel()
|
||||
let orderNumber: String
|
||||
let onBound: () -> Void
|
||||
|
||||
@State private var binding = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if viewModel.step == .selectAcquirer {
|
||||
acquirerList
|
||||
} else {
|
||||
referralOrderList
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.step == .selectAcquirer ? "选择获客员" : "选择带客单")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button {
|
||||
if viewModel.step == .selectReferralOrder && viewModel.acquirers.count > 1 {
|
||||
viewModel.backToAcquirerStep()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if viewModel.step == .selectReferralOrder {
|
||||
Button {
|
||||
Task { await confirmBind() }
|
||||
} label: {
|
||||
Text(binding ? "提交中..." : "确认绑定")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(
|
||||
viewModel.selectedReferralOrder != nil
|
||||
? OrderSourceColors.primary
|
||||
: OrderSourceColors.primary.opacity(0.4),
|
||||
in: RoundedRectangle(cornerRadius: 8)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.selectedReferralOrder == nil || binding)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.white)
|
||||
}
|
||||
}
|
||||
.background(OrderSourceColors.pageBg.ignoresSafeArea())
|
||||
.task {
|
||||
await globalLoading.withLoading {
|
||||
await viewModel.initOrder(
|
||||
orderNumber: orderNumber,
|
||||
api: cooperationOrderAPI,
|
||||
storeId: accountContext.currentStore?.id
|
||||
)
|
||||
}
|
||||
if viewModel.acquirers.isEmpty {
|
||||
toastCenter.show("暂无合作获客员,请先绑定获客员")
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectAcquirer(_ acquirer: CooperativeSaler) async {
|
||||
await globalLoading.withLoading {
|
||||
await viewModel.selectAcquirer(acquirer, api: cooperationOrderAPI)
|
||||
}
|
||||
}
|
||||
|
||||
private var acquirerList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
|
||||
AcquirerSelectCard(
|
||||
name: acquirer.displayName,
|
||||
phone: acquirer.displayPhone
|
||||
) {
|
||||
Task { await selectAcquirer(acquirer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private var referralOrderList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let acquirer = viewModel.selectedAcquirer, !acquirer.displayName.isEmpty {
|
||||
Text("获客员:\(acquirer.displayName)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(OrderSourceColors.text666)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
if viewModel.hasLoadedReferralOrdersOnce && viewModel.referralOrders.isEmpty {
|
||||
Text("暂无可绑定带客单")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(OrderSourceColors.text999)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 32)
|
||||
} else {
|
||||
ForEach(Array(viewModel.referralOrders.enumerated()), id: \.element.id) { index, order in
|
||||
let selected = viewModel.selectedReferralOrder?.id == order.id
|
||||
ReferralOrderSelectCard(
|
||||
order: order,
|
||||
selected: selected
|
||||
) {
|
||||
viewModel.selectReferralOrder(order)
|
||||
}
|
||||
.onAppear {
|
||||
guard index >= viewModel.referralOrders.count - 3 else { return }
|
||||
guard viewModel.canLoadMore, !viewModel.isLoadingMore else { return }
|
||||
Task { await viewModel.loadMoreReferralOrders(api: cooperationOrderAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.tint(OrderSourceColors.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.refreshable {
|
||||
await globalLoading.withOptionalLoading(false) {
|
||||
await viewModel.refreshReferralOrders(api: cooperationOrderAPI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func confirmBind() async {
|
||||
binding = true
|
||||
defer { binding = false }
|
||||
do {
|
||||
try await viewModel.confirmBind(api: cooperationOrderAPI)
|
||||
onBound()
|
||||
dismiss()
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单号 Sheet 标识,用于导航到带客单选择页。
|
||||
struct OrderNumberSheetItem: Identifiable {
|
||||
let orderNumber: String
|
||||
var id: String { orderNumber }
|
||||
}
|
||||
@ -86,7 +86,7 @@ final class ScenicSelectionViewModel: ObservableObject {
|
||||
accountContext.selectScenic(id: scenicId)
|
||||
snapshotStore.saveCurrentSelection(
|
||||
accountContext: accountContext,
|
||||
currentRoleId: permissionContext.currentRole?.id
|
||||
currentRoleCode: permissionContext.currentAppRole?.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单
|
||||
|
||||
切换时间段时同时请求汇总接口和第一页日数据接口。汇总接口使用 `range` 参数,日数据接口使用 `start_time`、`end_time`、`page` 和 `page_size`。
|
||||
|
||||
`roleId == 53` 时使用景区管理员接口,其他角色使用摄影师接口。日数据响应使用 `DataListPayload` 解码,兼容后端返回 `data` 或 `list` 字段。
|
||||
`currentAppRole.isScenicAdmin == true` 时使用景区管理员接口,其他角色使用摄影师接口。日数据响应使用 `DataListPayload` 解码,兼容后端返回 `data` 或 `list` 字段。
|
||||
|
||||
## 分页规则
|
||||
|
||||
|
||||
@ -28,13 +28,13 @@ final class StatisticsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
/// 切换统计时间段,并重置分页重新加载。
|
||||
func selectPeriod(_ period: StatisticsPeriod, api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
func selectPeriod(_ period: StatisticsPeriod, api: StatisticsServing, scenicId: Int?, appRole: AppRoleCode?) async throws {
|
||||
selectedPeriod = period
|
||||
try await reload(api: api, scenicId: scenicId, roleId: roleId)
|
||||
try await reload(api: api, scenicId: scenicId, appRole: appRole)
|
||||
}
|
||||
|
||||
/// 刷新统计汇总和第一页日数据,缺少景区时清空旧状态。
|
||||
func reload(api: StatisticsServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
func reload(api: StatisticsServing, scenicId: Int?, appRole: AppRoleCode?, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
@ -43,7 +43,7 @@ final class StatisticsViewModel: ObservableObject {
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let isScenicAdmin = roleId == 53
|
||||
let isScenicAdmin = appRole?.isScenicAdmin == true
|
||||
async let summaryResult = api.summary(
|
||||
scenicId: scenicId,
|
||||
range: selectedPeriod.apiRange,
|
||||
@ -66,7 +66,7 @@ final class StatisticsViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
/// 加载每日统计下一页。
|
||||
func loadMore(api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
func loadMore(api: StatisticsServing, scenicId: Int?, appRole: AppRoleCode?) async throws {
|
||||
guard !loadingMore, hasMore, let scenicId else { return }
|
||||
|
||||
loadingMore = true
|
||||
@ -79,7 +79,7 @@ final class StatisticsViewModel: ObservableObject {
|
||||
endTime: selectedPeriod.apiEndTime,
|
||||
page: nextPage,
|
||||
pageSize: pageSize,
|
||||
isScenicAdmin: roleId == 53
|
||||
isScenicAdmin: appRole?.isScenicAdmin == true
|
||||
)
|
||||
dailyItems.append(contentsOf: result.data)
|
||||
totalDailyCount = result.total
|
||||
|
||||
@ -22,8 +22,8 @@ struct StatisticsView: View {
|
||||
accountContext.currentScenic?.id
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
permissionContext.currentRole?.id
|
||||
private var currentAppRole: AppRoleCode? {
|
||||
permissionContext.currentAppRole
|
||||
}
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
@ -62,7 +62,7 @@ struct StatisticsView: View {
|
||||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
@ -289,7 +289,7 @@ struct StatisticsView: View {
|
||||
private func selectPeriod(_ period: StatisticsPeriod) async {
|
||||
do {
|
||||
try await globalLoading.withLoading(message: "加载中...") {
|
||||
try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||
try await viewModel.selectPeriod(period, api: statisticsAPI, scenicId: currentScenicId, appRole: currentAppRole)
|
||||
}
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
@ -299,7 +299,7 @@ struct StatisticsView: View {
|
||||
private func reload(showLoading: Bool = true) async {
|
||||
do {
|
||||
try await globalLoading.withOptionalLoading(showLoading && currentScenicId != nil && viewModel.dailyItems.isEmpty, message: "加载中...") {
|
||||
try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId, showLoading: showLoading)
|
||||
try await viewModel.reload(api: statisticsAPI, scenicId: currentScenicId, appRole: currentAppRole, showLoading: showLoading)
|
||||
}
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
@ -308,7 +308,7 @@ struct StatisticsView: View {
|
||||
|
||||
private func loadMore() async {
|
||||
do {
|
||||
try await viewModel.loadMore(api: statisticsAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||
try await viewModel.loadMore(api: statisticsAPI, scenicId: currentScenicId, appRole: currentAppRole)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user