diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 1af304d..d168fb9 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,15 +64,11 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanUITests; sourceTree = ""; }; @@ -327,10 +323,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/App/AppServiceEnvironment.swift b/suixinkan/App/AppServiceEnvironment.swift index 33fbbc0..11fa053 100644 --- a/suixinkan/App/AppServiceEnvironment.swift +++ b/suixinkan/App/AppServiceEnvironment.swift @@ -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 } + } } diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 5f687c4..6108e0e 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -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 } diff --git a/suixinkan/App/State/AccountContextLoader.swift b/suixinkan/App/State/AccountContextLoader.swift index 2981263..1733ff9 100644 --- a/suixinkan/App/State/AccountContextLoader.swift +++ b/suixinkan/App/State/AccountContextLoader.swift @@ -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) diff --git a/suixinkan/App/State/AuthSessionCoordinator.swift b/suixinkan/App/State/AuthSessionCoordinator.swift index ddb6b6f..332f438 100644 --- a/suixinkan/App/State/AuthSessionCoordinator.swift +++ b/suixinkan/App/State/AuthSessionCoordinator.swift @@ -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, diff --git a/suixinkan/App/State/PermissionContext.swift b/suixinkan/App/State/PermissionContext.swift index 22c23fe..7f8ad25 100644 --- a/suixinkan/App/State/PermissionContext.swift +++ b/suixinkan/App/State/PermissionContext.swift @@ -15,20 +15,94 @@ final class PermissionContext: ObservableObject { @Published private(set) var permissionURIs: Set = [] @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。 diff --git a/suixinkan/App/State/SessionBootstrapper.swift b/suixinkan/App/State/SessionBootstrapper.swift index f6c7a5d..678100f 100644 --- a/suixinkan/App/State/SessionBootstrapper.swift +++ b/suixinkan/App/State/SessionBootstrapper.swift @@ -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, diff --git a/suixinkan/Core/Models/AppRoleCode.swift b/suixinkan/Core/Models/AppRoleCode.swift new file mode 100644 index 0000000..a64556b --- /dev/null +++ b/suixinkan/Core/Models/AppRoleCode.swift @@ -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 = [ + .storeAdmin, + .editor, + .scenicCS, + .scenicAdmin, + .scenicOperation, + ] +} diff --git a/suixinkan/Core/Models/RolePermissionMatching.swift b/suixinkan/Core/Models/RolePermissionMatching.swift new file mode 100644 index 0000000..65a2dbf --- /dev/null +++ b/suixinkan/Core/Models/RolePermissionMatching.swift @@ -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 + } +} diff --git a/suixinkan/Core/Storage/AccountSnapshotStore.swift b/suixinkan/Core/Storage/AccountSnapshotStore.swift index 125bbe5..cfad2f4 100644 --- a/suixinkan/Core/Storage/AccountSnapshotStore.swift +++ b/suixinkan/Core/Storage/AccountSnapshotStore.swift @@ -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, diff --git a/suixinkan/Core/Utils/SaleUserQrParser.swift b/suixinkan/Core/Utils/SaleUserQrParser.swift new file mode 100644 index 0000000..7d84caf --- /dev/null +++ b/suixinkan/Core/Utils/SaleUserQrParser.swift @@ -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 + } +} diff --git a/suixinkan/Features/Account/Account.md b/suixinkan/Features/Account/Account.md index 8ae4a93..7f37aef 100644 --- a/suixinkan/Features/Account/Account.md +++ b/suixinkan/Features/Account/Account.md @@ -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 做隔离。 diff --git a/suixinkan/Features/Account/Models/AccountContextModels.swift b/suixinkan/Features/Account/Models/AccountContextModels.swift index bae8078..6e9396d 100644 --- a/suixinkan/Features/Account/Models/AccountContextModels.swift +++ b/suixinkan/Features/Account/Models/AccountContextModels.swift @@ -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) ?? [] } diff --git a/suixinkan/Features/Auth/Models/AuthModels.swift b/suixinkan/Features/Auth/Models/AuthModels.swift index a02b708..b23c17e 100644 --- a/suixinkan/Features/Auth/Models/AuthModels.swift +++ b/suixinkan/Features/Auth/Models/AuthModels.swift @@ -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) } } diff --git a/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift b/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift new file mode 100644 index 0000000..f422cee --- /dev/null +++ b/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift @@ -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 + + /// 获取获客订单列表。 + func saleUserAcquisitionOrders(keyword: String?, page: Int, pageSize: Int) async throws -> ListPayload + + /// 获取可绑定带客单列表。 + 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 { + 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 { + 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) + ) + ) + } +} diff --git a/suixinkan/Features/CooperationOrder/CooperationOrder.md b/suixinkan/Features/CooperationOrder/CooperationOrder.md new file mode 100644 index 0000000..03a05c6 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/CooperationOrder.md @@ -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 加载线索图片 diff --git a/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift b/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift new file mode 100644 index 0000000..ed94807 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/CooperationOrder/Services/CooperationOrderFeature.swift b/suixinkan/Features/CooperationOrder/Services/CooperationOrderFeature.swift new file mode 100644 index 0000000..d2b56fe --- /dev/null +++ b/suixinkan/Features/CooperationOrder/Services/CooperationOrderFeature.swift @@ -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) + } +} diff --git a/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift new file mode 100644 index 0000000..a99c6c0 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift @@ -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 + } + } +} diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift new file mode 100644 index 0000000..f429a65 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift @@ -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 + } +} diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationOrderListViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationOrderListViewModel.swift new file mode 100644 index 0000000..ad630fc --- /dev/null +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationOrderListViewModel.swift @@ -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 + } +} diff --git a/suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift b/suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift new file mode 100644 index 0000000..c53158a --- /dev/null +++ b/suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift @@ -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() + } + } + } +} diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift b/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift new file mode 100644 index 0000000..ef3b6cd --- /dev/null +++ b/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift @@ -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) + } +} diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationOrderAccessView.swift b/suixinkan/Features/CooperationOrder/Views/CooperationOrderAccessView.swift new file mode 100644 index 0000000..e217c22 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/Views/CooperationOrderAccessView.swift @@ -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() + } + } + } + } +} diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift b/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift new file mode 100644 index 0000000..d3abcd3 --- /dev/null +++ b/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift @@ -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: 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 { + 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))" +} diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationOrderListView.swift b/suixinkan/Features/CooperationOrder/Views/CooperationOrderListView.swift new file mode 100644 index 0000000..d5f718a --- /dev/null +++ b/suixinkan/Features/CooperationOrder/Views/CooperationOrderListView.swift @@ -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) + } +} diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 25086c1..bd8daf0 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -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 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index ed8fcb9..cbfb7f7 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -56,6 +56,7 @@ enum HomeRoute: Hashable { case locationReportHistory case depositOrders case withdrawalAudit + case cooperationOrder case scenicSettlement case scenicSettlementReview case messageCenter diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 1b4f9e6..05bf611 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -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 } diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index fecb31d..bcc1b8b 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -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] { + 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] { - 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() return uris.filter { uri in seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index 48eb747..15fc493 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -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 } diff --git a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift index 23d1fa6..9c74450 100644 --- a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift +++ b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift @@ -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) } } diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 7f7c942..9329635 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -88,6 +88,8 @@ extension HomeRoute { DepositOrderEntryView() case .withdrawalAudit: WithdrawalAuditView() + case .cooperationOrder: + CooperationOrderAccessView() case .scenicSettlement: ScenicSettlementView() case .scenicSettlementReview: diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index 5ba0d10..a602a90 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -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 = [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) } } diff --git a/suixinkan/Features/Main/Views/MainTabsView.swift b/suixinkan/Features/Main/Views/MainTabsView.swift index f61347a..b138e8d 100644 --- a/suixinkan/Features/Main/Views/MainTabsView.swift +++ b/suixinkan/Features/Main/Views/MainTabsView.swift @@ -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 { + Binding( + get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) }, + set: { bindAcquirerSaleUserId = $0?.saleUserId } + ) + } + /// 处理系统 TabBar 选择变化,扫码入口只弹出页面并立即恢复之前的业务 Tab 选中态。 private func handleTabSelection(_ selection: MainTabSelection) { switch selection { diff --git a/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift b/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift index 0523a98..cc8448e 100644 --- a/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift +++ b/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift @@ -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 diff --git a/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift b/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift index 695e35c..56f1ec4 100644 --- a/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift +++ b/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift @@ -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 { diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift index 1d33233..5c015ed 100644 --- a/suixinkan/Features/Orders/Models/OrderModels.swift +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -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) } } diff --git a/suixinkan/Features/Orders/Models/OrderSourceModels.swift b/suixinkan/Features/Orders/Models/OrderSourceModels.swift new file mode 100644 index 0000000..96ca427 --- /dev/null +++ b/suixinkan/Features/Orders/Models/OrderSourceModels.swift @@ -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: [] + ) + ) + } +} diff --git a/suixinkan/Features/Orders/Orders.md b/suixinkan/Features/Orders/Orders.md index 63c70bf..72d4135 100644 --- a/suixinkan/Features/Orders/Orders.md +++ b/suixinkan/Features/Orders/Orders.md @@ -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。 diff --git a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift index c3eb0aa..fb37b4c 100644 --- a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift @@ -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 { diff --git a/suixinkan/Features/Orders/Views/OrderDetailViews.swift b/suixinkan/Features/Orders/Views/OrderDetailViews.swift index 57ea209..1c91f78 100644 --- a/suixinkan/Features/Orders/Views/OrderDetailViews.swift +++ b/suixinkan/Features/Orders/Views/OrderDetailViews.swift @@ -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) diff --git a/suixinkan/Features/Orders/Views/OrderSourceComponents.swift b/suixinkan/Features/Orders/Views/OrderSourceComponents.swift new file mode 100644 index 0000000..040c539 --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderSourceComponents.swift @@ -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.. 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) + } +} diff --git a/suixinkan/Features/Orders/Views/OrderSourcePicker.swift b/suixinkan/Features/Orders/Views/OrderSourcePicker.swift new file mode 100644 index 0000000..de35b77 --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderSourcePicker.swift @@ -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 { + 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 +} diff --git a/suixinkan/Features/Orders/Views/OrdersView.swift b/suixinkan/Features/Orders/Views/OrdersView.swift index 92353cd..3b4bc95 100644 --- a/suixinkan/Features/Orders/Views/OrdersView.swift +++ b/suixinkan/Features/Orders/Views/OrdersView.swift @@ -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 { + Binding( + get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) }, + set: { bindAcquirerSaleUserId = $0?.saleUserId } + ) + } + + private var referralBindBinding: Binding { + 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 { diff --git a/suixinkan/Features/Orders/Views/ReferralOrderSelectView.swift b/suixinkan/Features/Orders/Views/ReferralOrderSelectView.swift new file mode 100644 index 0000000..49581cd --- /dev/null +++ b/suixinkan/Features/Orders/Views/ReferralOrderSelectView.swift @@ -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 } +} diff --git a/suixinkan/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift b/suixinkan/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift index 1e7a247..d2c9100 100644 --- a/suixinkan/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift +++ b/suixinkan/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift @@ -86,7 +86,7 @@ final class ScenicSelectionViewModel: ObservableObject { accountContext.selectScenic(id: scenicId) snapshotStore.saveCurrentSelection( accountContext: accountContext, - currentRoleId: permissionContext.currentRole?.id + currentRoleCode: permissionContext.currentAppRole?.rawValue ) } diff --git a/suixinkan/Features/Statistics/Statistics.md b/suixinkan/Features/Statistics/Statistics.md index d56a51e..0c3c600 100644 --- a/suixinkan/Features/Statistics/Statistics.md +++ b/suixinkan/Features/Statistics/Statistics.md @@ -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` 字段。 ## 分页规则 diff --git a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift index e2c1d69..98e4ad3 100644 --- a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift +++ b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift @@ -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 diff --git a/suixinkan/Features/Statistics/Views/StatisticsView.swift b/suixinkan/Features/Statistics/Views/StatisticsView.swift index 2e7ceee..3d55840 100644 --- a/suixinkan/Features/Statistics/Views/StatisticsView.swift +++ b/suixinkan/Features/Statistics/Views/StatisticsView.swift @@ -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) } diff --git a/suixinkanTests/AppRoleCodeTests.swift b/suixinkanTests/AppRoleCodeTests.swift new file mode 100644 index 0000000..550022e --- /dev/null +++ b/suixinkanTests/AppRoleCodeTests.swift @@ -0,0 +1,93 @@ +// +// AppRoleCodeTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +final class AppRoleCodeTests: XCTestCase { + func testFromCodeParsesKnownRoles() { + XCTAssertEqual(AppRoleCode.fromCode("store_admin"), .storeAdmin) + XCTAssertEqual(AppRoleCode.fromCode("scenic_admin"), .scenicAdmin) + XCTAssertEqual(AppRoleCode.fromCode("photographer"), .photographer) + } + + func testFromLegacyRoleIdMapsKnownIds() { + XCTAssertEqual(AppRoleCode.fromLegacyRoleId(46), .storeAdmin) + XCTAssertEqual(AppRoleCode.fromLegacyRoleId(53), .scenicAdmin) + XCTAssertEqual(AppRoleCode.fromLegacyRoleId(41), .photographer) + } + + func testFromRoleNameAvoidsAssistantMisclassification() { + XCTAssertEqual(AppRoleCode.fromRoleName("摄影师助理"), .photographerAssistant) + XCTAssertEqual(AppRoleCode.fromRoleName("资深摄影师"), .photographer) + } + + func testHomeMinimalTopRolesContainsManagementRoles() { + XCTAssertTrue(AppRoleCode.homeMinimalTopRoles.contains(.storeAdmin)) + XCTAssertTrue(AppRoleCode.homeMinimalTopRoles.contains(.scenicAdmin)) + XCTAssertFalse(AppRoleCode.homeMinimalTopRoles.contains(.photographer)) + } +} + +final class RolePermissionMatchingTests: XCTestCase { + func testMatchPrefersRoleCodeOverLegacyId() { + let permissions = [ + RolePermissionResponse( + role: RoleInfo(id: 10, name: "摄影师", roleCode: "photographer", notes: nil, permission: []), + scenic: [] + ), + RolePermissionResponse( + role: RoleInfo(id: 53, name: "景区管理员", roleCode: "scenic_admin", notes: nil, permission: []), + scenic: [] + ) + ] + + let matched = RolePermissionMatching.match( + in: permissions, + roleCode: "scenic_admin", + roleId: 10, + roleName: "摄影师" + ) + + XCTAssertEqual(matched?.role.id, 53) + } + + func testResolveRoleCodeMigratesLegacySession() { + let resolved = RolePermissionMatching.resolveRoleCode( + roleCode: nil, + roleId: 46, + roleName: nil, + permissions: [] + ) + + XCTAssertEqual(resolved, .storeAdmin) + } +} + +final class AccountSnapshotMigrationTests: XCTestCase { + func testMigrateLegacyRoleIdToRoleCode() { + var snapshot = AccountSnapshot(currentRoleId: 53) + snapshot.migrateLegacyRoleIfNeeded() + XCTAssertEqual(snapshot.currentRoleCode, "scenic_admin") + XCTAssertNil(snapshot.currentRoleId) + } + + func testLoadMigratesAndPersistsLegacySnapshot() { + let defaults = UserDefaults(suiteName: "AccountSnapshotMigrationTests")! + defaults.removePersistentDomain(forName: "AccountSnapshotMigrationTests") + let store = AccountSnapshotStore(defaults: defaults, key: "test.snapshot") + store.save(AccountSnapshot(currentRoleId: 46)) + + let loaded = store.load() + XCTAssertEqual(loaded?.currentRoleCode, "store_admin") + XCTAssertNil(loaded?.currentRoleId) + + let persisted = store.load() + XCTAssertEqual(persisted?.currentRoleCode, "store_admin") + XCTAssertNil(persisted?.currentRoleId) + } +} diff --git a/suixinkanTests/CooperationOrderAPITests.swift b/suixinkanTests/CooperationOrderAPITests.swift new file mode 100644 index 0000000..86d4f9c --- /dev/null +++ b/suixinkanTests/CooperationOrderAPITests.swift @@ -0,0 +1,196 @@ +// +// CooperationOrderAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 合作订单 API 测试,覆盖解码与请求参数。 +final class CooperationOrderAPITests: XCTestCase { + /// 测试合作获客员列表解码与查询参数。 + func testCooperativeSalersDecodesFixtureAndUsesExpectedQuery() async throws { + let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "cooperative_salers_success")) + let api = CooperationOrderAPI(client: APIClient(session: session)) + + let response = try await api.cooperativeSalers(storeId: 66, page: 0, pageSize: 0) + + XCTAssertEqual(response.total, 1) + XCTAssertEqual(response.page, 1) + XCTAssertEqual(response.pageSize, 50) + XCTAssertEqual(response.list.first?.displayId, 101) + XCTAssertEqual(response.list.first?.displayName, "管铜彬") + XCTAssertEqual(response.list.first?.displayPhone, "19281871413") + XCTAssertEqual(response.list.first?.bindingId, 5) + XCTAssertEqual(response.list.first?.shareCode, "INV-000101") + + let request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cooperative-salers") + let query = cooperationQueryItems(from: request) + XCTAssertEqual(query["store_id"], "66") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + } + + /// 测试获客员邀请信息解码。 + func testSaleUserInviteInfoDecodesFixture() async throws { + let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "sale_user_invite_info_success")) + let api = CooperationOrderAPI(client: APIClient(session: session)) + + let info = try await api.saleUserInviteInfo(saleUserId: 101) + + XCTAssertEqual(info.saleUserId, 101) + XCTAssertFalse(info.isBound) + XCTAssertEqual(info.photographerName, "陆久银") + XCTAssertEqual(info.photographerPhone, "18651857230") + XCTAssertEqual(info.salerName, "管铜彬") + XCTAssertEqual(info.salerPhone, "13222319413") + XCTAssertEqual(info.shareCode, "INV-000101") + XCTAssertEqual(info.subtitle, "确认与获客员绑定,可以绑定多个获客员") + XCTAssertEqual(info.title, "绑定获客员") + + let request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/invite-info") + XCTAssertEqual(cooperationQueryItems(from: request)["sale_user_id"], "101") + } + + /// 测试获客线索列表解码。 + func testSaleUserReferralLeadsDecodesFixture() async throws { + let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "sale_user_referral_leads_success")) + let api = CooperationOrderAPI(client: APIClient(session: session)) + + let response = try await api.saleUserReferralLeads(page: 2, pageSize: 20) + + XCTAssertEqual(response.total, 1) + XCTAssertEqual(response.list.first?.displayId, "501") + XCTAssertEqual(response.list.first?.displayPhone, "13900000002") + XCTAssertEqual(response.list.first?.remark, "景区门口获客") + XCTAssertEqual(response.list.first?.displayImages.first, "https://example.com/lead.jpg") + + let request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/referral-leads") + let query = cooperationQueryItems(from: request) + XCTAssertEqual(query["page"], "2") + XCTAssertEqual(query["page_size"], "20") + } + + /// 测试获客订单列表 keyword 查询。 + func testSaleUserAcquisitionOrdersDecodesFixtureWithKeyword() async throws { + let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "sale_user_acquisition_orders_success")) + let api = CooperationOrderAPI(client: APIClient(session: session)) + + let response = try await api.saleUserAcquisitionOrders(keyword: "138", page: 1, pageSize: 10) + + XCTAssertEqual(response.total, 1) + XCTAssertEqual(response.list.first?.orderNumber, "ACQ001") + XCTAssertEqual(response.list.first?.displayAmount, "299.00") + XCTAssertEqual(response.list.first?.displayCustomerPhone, "13700000003") + XCTAssertEqual(response.list.first?.displayPartner, "张三") + + let request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/acquisition-orders") + let query = cooperationQueryItems(from: request) + XCTAssertEqual(query["keyword"], "138") + } + + /// 测试可绑定带客单列表解码。 + func testSaleUserReferralOrdersDecodesFixture() async throws { + let session = CooperationRecordingURLSession(data: try TestFixture.data(named: "sale_user_referral_orders_success")) + let api = CooperationOrderAPI(client: APIClient(session: session)) + + let response = try await api.saleUserReferralOrders(saleUserId: 101, page: 1, pageSize: 10) + + XCTAssertEqual(response.total, 1) + XCTAssertEqual(response.page, 1) + XCTAssertEqual(response.pageSize, 50) + XCTAssertEqual(response.saler?.saleUserId, 101) + XCTAssertEqual(response.saler?.salerName, "管铜彬") + XCTAssertEqual(response.list.first?.id, 54) + XCTAssertEqual(response.list.first?.displayReferralOrderNo, "LD202606250009") + XCTAssertEqual(response.list.first?.userMobile, "13322222223") + XCTAssertEqual(response.list.first?.remark, "带客备注") + XCTAssertEqual(response.list.first?.statusLabel, "未完成") + XCTAssertEqual(response.list.first?.images.count, 1) + + let request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/referral-orders") + let query = cooperationQueryItems(from: request) + XCTAssertEqual(query["sale_user_id"], "101") + } + + /// 测试接受绑定、绑定带客单与更新状态请求。 + func testSaleUserAcceptBindAndBindReferralOrderUsePostQuery() async throws { + let emptyData = try TestFixture.data(named: "empty_success") + + let acceptSession = CooperationRecordingURLSession(data: emptyData) + let acceptAPI = CooperationOrderAPI(client: APIClient(session: acceptSession)) + try await acceptAPI.saleUserAcceptBind(saleUserId: 101) + + let acceptRequest = try XCTUnwrap(acceptSession.requests.last) + XCTAssertEqual(acceptRequest.httpMethod, "POST") + XCTAssertEqual(acceptRequest.url?.path, "/api/yf-handset-app/photog/sale-user/accept-bind") + XCTAssertEqual(cooperationQueryItems(from: acceptRequest)["sale_user_id"], "101") + XCTAssertNil(acceptRequest.httpBody) + + let bindSession = CooperationRecordingURLSession(data: emptyData) + let bindAPI = CooperationOrderAPI(client: APIClient(session: bindSession)) + try await bindAPI.saleUserBindReferralOrder(orderNumber: "ORD001", referralOrderNo: "REF001") + + let bindRequest = try XCTUnwrap(bindSession.requests.last) + XCTAssertEqual(bindRequest.httpMethod, "POST") + XCTAssertEqual(bindRequest.url?.path, "/api/yf-handset-app/photog/sale-user/bind-referral-order") + let bindQuery = cooperationQueryItems(from: bindRequest) + XCTAssertEqual(bindQuery["order_number"], "ORD001") + XCTAssertEqual(bindQuery["referral_order_no"], "REF001") + XCTAssertNil(bindRequest.httpBody) + + let statusSession = CooperationRecordingURLSession(data: emptyData) + let statusAPI = CooperationOrderAPI(client: APIClient(session: statusSession)) + try await statusAPI.saleUserUpdateReferralOrderStatus(id: 15, status: 3) + + let statusRequest = try XCTUnwrap(statusSession.requests.last) + XCTAssertEqual(statusRequest.httpMethod, "POST") + XCTAssertEqual( + statusRequest.url?.path, + "/api/yf-handset-app/photog/sale-user/referral-orders/15/status" + ) + let statusBody = try XCTUnwrap(statusRequest.httpBody) + let statusJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: statusBody) as? [String: Any]) + XCTAssertEqual(statusJSON["status"] as? Int, 3) + } +} + +/// URLSession 测试替身,记录合作订单 API 请求。 +private final class CooperationRecordingURLSession: URLSessionProtocol { + let data: Data + var statusCode: Int + private(set) var requests: [URLRequest] = [] + + init(data: Data, statusCode: Int = 200) { + self.data = data + self.statusCode = statusCode + } + + /// 返回固定数据和 HTTP 响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + ) + } +} + +private func cooperationQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkanTests/Fixtures/cooperative_salers_success.json b/suixinkanTests/Fixtures/cooperative_salers_success.json new file mode 100644 index 0000000..bef9060 --- /dev/null +++ b/suixinkanTests/Fixtures/cooperative_salers_success.json @@ -0,0 +1,24 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 1, + "page": 1, + "page_size": 50, + "list": [ + { + "bind_time": "2026-06-25 17:11:01", + "binding_id": 5, + "bound_at": "2026-06-25 17:11:01", + "name": "管铜彬", + "phone": "19281871413", + "phone_masked": "192****1413", + "sale_user_id": 101, + "saler_name": "管铜彬", + "saler_phone": "19281871413", + "share_code": "INV-000101", + "store_id": 37 + } + ] + } +} diff --git a/suixinkanTests/Fixtures/sale_user_acquisition_orders_success.json b/suixinkanTests/Fixtures/sale_user_acquisition_orders_success.json new file mode 100644 index 0000000..d5ec4f2 --- /dev/null +++ b/suixinkanTests/Fixtures/sale_user_acquisition_orders_success.json @@ -0,0 +1,20 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "1", + "list": [ + { + "id": 801, + "order_number": "ACQ001", + "project_name": "亲子跟拍", + "order_type": 11, + "actual_pay_amount": "299.00", + "customer_phone": "13700000003", + "order_status_name": "已完成", + "sale_user_name": "张三", + "created_at": "2026-05-22 16:00:00" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/sale_user_invite_info_success.json b/suixinkanTests/Fixtures/sale_user_invite_info_success.json new file mode 100644 index 0000000..71bff63 --- /dev/null +++ b/suixinkanTests/Fixtures/sale_user_invite_info_success.json @@ -0,0 +1,15 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "is_bound": false, + "photographer_name": "陆久银", + "photographer_phone": "18651857230", + "sale_user_id": 101, + "saler_name": "管铜彬", + "saler_phone": "13222319413", + "share_code": "INV-000101", + "subtitle": "确认与获客员绑定,可以绑定多个获客员", + "title": "绑定获客员" + } +} diff --git a/suixinkanTests/Fixtures/sale_user_referral_leads_success.json b/suixinkanTests/Fixtures/sale_user_referral_leads_success.json new file mode 100644 index 0000000..f369d8f --- /dev/null +++ b/suixinkanTests/Fixtures/sale_user_referral_leads_success.json @@ -0,0 +1,16 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "1", + "list": [ + { + "lead_id": 501, + "user_phone": "13900000002", + "remark": "景区门口获客", + "created_at": "2026-05-21 14:00:00", + "image_urls": ["https://example.com/lead.jpg"] + } + ] + } +} diff --git a/suixinkanTests/Fixtures/sale_user_referral_orders_success.json b/suixinkanTests/Fixtures/sale_user_referral_orders_success.json new file mode 100644 index 0000000..cf0232d --- /dev/null +++ b/suixinkanTests/Fixtures/sale_user_referral_orders_success.json @@ -0,0 +1,29 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 1, + "page": 1, + "page_size": 50, + "saler": { + "sale_user_id": 101, + "saler_name": "管铜彬", + "saler_phone": "19281871413" + }, + "list": [ + { + "id": 54, + "referral_order_no": "LD202606250009", + "order_no": "LD202606250009", + "user_mobile": "13322222223", + "remark": "带客备注", + "created_at": "2026-06-25 17:18:52", + "status": 1, + "status_label": "未完成", + "images": [ + "https://vipsky.oss-cn-shanghai.aliyuncs.com/sales/referral-order/create/6/1782379129957_hd2z9x.jpg" + ] + } + ] + } +} diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index 00a0793..a7b670b 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -10,38 +10,117 @@ import XCTest /// 首页常用应用存储测试,覆盖默认值、过滤和别名去重。 final class HomeCommonMenuStoreTests: XCTestCase { - /// 测试首次无配置时会生成默认常用应用。 - func testLoadCreatesDefaultCommonMenusWhenBaselineIsMissing() { + /// 测试首次无配置时会按顶层权限生成默认常用应用。 + func testLoadCreatesDefaultCommonMenusFromTopLevelPermissions() { let defaults = makeIsolatedDefaults() let store = HomeCommonMenuStore(defaults: defaults) let menus = [ - menu("registration_invitation"), - menu("location_report"), - menu("pm_manager") + menu("space_settings"), + menu("wallet"), + menu("cloud_management"), + menu("message_center") ] - let uris = store.load(menuItems: menus) + let uris = store.load( + menuItems: menus, + roleCode: "photographer", + topLevelPermissionURIs: ["space_settings", "wallet", "cloud_management", "message_center"] + ) - XCTAssertEqual(uris, ["registration_invitation", "location_report", "pm_manager"]) - XCTAssertTrue(defaults.bool(forKey: HomeCommonMenuStore.defaultBaselineKey)) + XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "message_center"]) + XCTAssertEqual( + defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"), + ["space_settings", "wallet", "cloud_management", "message_center"] + ) + } + + /// 测试顶层权限超过 4 个时只取前 4 个默认常用应用。 + func testDefaultCommonURIsTakeFirstFourWhenMoreThanFourPermissions() { + let availableURIs: Set = [ + "space_settings", + "wallet", + "cloud_management", + "message_center", + "task_management" + ] + let uris = HomeCommonMenuStore.defaultCommonURIs( + orderedTopLevelURIs: [ + "space_settings", + "wallet", + "cloud_management", + "message_center", + "task_management" + ], + availableURIs: availableURIs + ) + + XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "message_center"]) + } + + /// 测试角色没有旧硬编码默认 URI 时仍能从权限列表生成常用应用。 + func testLoadCreatesDefaultsWithoutLegacyPreferredURIs() { + let defaults = makeIsolatedDefaults() + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [ + menu("space_settings"), + menu("wallet"), + menu("cloud_management") + ] + + let uris = store.load( + menuItems: menus, + roleCode: "editor", + topLevelPermissionURIs: ["space_settings", "wallet", "cloud_management"] + ) + + XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"]) } /// 测试已保存 URI 会按当前权限过滤并解析同义 URI。 func testLoadFiltersSavedUrisByCurrentPermissions() { let defaults = makeIsolatedDefaults() - defaults.set(true, forKey: HomeCommonMenuStore.defaultBaselineKey) - defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.defaultStorageKey) + defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer") let store = HomeCommonMenuStore(defaults: defaults) let menus = [ menu("task_management_editor"), menu("project_edit") ] - let uris = store.load(menuItems: menus) + let uris = store.load( + menuItems: menus, + roleCode: "photographer", + topLevelPermissionURIs: ["task_management_editor", "project_edit"] + ) XCTAssertEqual(uris, ["task_management_editor", "project_edit"]) } + /// 测试不同角色使用独立存储 key。 + func testLoadUsesRoleScopedStorageKeys() { + let defaults = makeIsolatedDefaults() + defaults.set(["wallet"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer") + defaults.set(["cloud_management"], forKey: HomeCommonMenuStore.storageKeyPrefix + "editor") + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [ + menu("wallet"), + menu("cloud_management") + ] + + let roleOne = store.load( + menuItems: menus, + roleCode: "photographer", + topLevelPermissionURIs: ["wallet", "cloud_management"] + ) + let roleTwo = store.load( + menuItems: menus, + roleCode: "editor", + topLevelPermissionURIs: ["wallet", "cloud_management"] + ) + + XCTAssertEqual(roleOne, ["wallet"]) + XCTAssertEqual(roleTwo, ["cloud_management"]) + } + /// 测试添加常用应用时会按同义 URI 去重。 func testAddDeduplicatesAliases() { let defaults = makeIsolatedDefaults() @@ -50,7 +129,12 @@ final class HomeCommonMenuStoreTests: XCTestCase { menu("task_management_editor") ] - let uris = store.add("task_management", current: ["task_management_editor"], menuItems: menus) + let uris = store.add( + "task_management", + current: ["task_management_editor"], + menuItems: menus, + roleCode: "photographer" + ) XCTAssertEqual(uris, ["task_management_editor"]) } @@ -60,11 +144,45 @@ final class HomeCommonMenuStoreTests: XCTestCase { let defaults = makeIsolatedDefaults() let store = HomeCommonMenuStore(defaults: defaults) - let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"]) + let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"], roleCode: "photographer") XCTAssertEqual(uris, ["pm_manager", "location_report"]) } + /// 测试无 role_code 时不读写常用应用。 + func testLoadReturnsEmptyWhenRoleCodeIsMissing() { + let defaults = makeIsolatedDefaults() + let store = HomeCommonMenuStore(defaults: defaults) + + let uris = store.load( + menuItems: [menu("wallet")], + roleCode: nil, + topLevelPermissionURIs: ["wallet"] + ) + + XCTAssertTrue(uris.isEmpty) + XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")) + } + + /// 测试从 legacy role id key 一次性迁移常用菜单。 + func testLoadMigratesLegacyRoleIdStorageKey() { + let defaults = makeIsolatedDefaults() + defaults.set(["wallet", "cloud_management"], forKey: HomeCommonMenuStore.storageKeyPrefix + "46") + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [menu("wallet"), menu("cloud_management")] + + let uris = store.load( + menuItems: menus, + roleCode: "store_admin", + legacyRoleId: 46, + topLevelPermissionURIs: ["wallet", "cloud_management"] + ) + + XCTAssertEqual(uris, ["wallet", "cloud_management"]) + XCTAssertEqual(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "store_admin"), ["wallet", "cloud_management"]) + XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "46")) + } + /// 创建测试菜单实体。 private func menu(_ uri: String) -> HomeMenuItem { HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil) diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 5a29b9a..587e8e2 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -57,6 +57,7 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "cooperation_order", title: ""), .destination(.cooperationOrder)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter)) @@ -150,6 +151,7 @@ final class HomeMenuRouterTests: XCTestCase { func testPermissionRouteAuditSeparatesRoutableUnsupportedAndUnknownEntries() { let permissions = [ rolePermission( + roleCode: "photographer", roleId: 1, permissions: [ permission(id: 1, name: "任务管理", uri: "task_management", children: [ @@ -162,7 +164,7 @@ final class HomeMenuRouterTests: XCTestCase { ) ] - let audit = HomePermissionRouteAuditor.audit(permissions: permissions, currentRoleId: 1) + let audit = HomePermissionRouteAuditor.audit(permissions: permissions, currentRoleCode: "photographer") XCTAssertEqual(audit.routable.map(\.uri), ["task_management", "task_create"]) XCTAssertEqual(audit.unsupported.map(\.uri), ["pilot_controller"]) @@ -214,9 +216,9 @@ final class HomeMenuRouterTests: XCTestCase { #endif /// 创建角色权限测试实体。 - private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { + private func rolePermission(roleCode: String, roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { RolePermissionResponse( - role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions), + role: RoleInfo(id: roleId, name: "角色\(roleId)", roleCode: roleCode, permission: permissions), scenic: [] ) } diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index ffe8053..7c8bd20 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -15,15 +15,15 @@ final class HomeViewModelTests: XCTestCase { func testBuildMenusClearsStaleMenusWhenCurrentRoleIsMissing() { let viewModel = HomeViewModel() let permissions = [ - rolePermission(roleId: 1, permissions: [ + rolePermission(roleCode: "photographer", roleId: 1, permissions: [ permission(id: 1, name: "消息中心", uri: "message_center") ]) ] - viewModel.buildMenus(from: permissions, currentRoleId: 1) + viewModel.buildMenus(from: permissions, currentRoleCode: "photographer") XCTAssertEqual(viewModel.menuItems.map(\.uri), ["message_center"]) - viewModel.buildMenus(from: permissions, currentRoleId: 99) + viewModel.buildMenus(from: permissions, currentRoleCode: "unknown_role") XCTAssertTrue(viewModel.menuItems.isEmpty) } @@ -32,15 +32,15 @@ final class HomeViewModelTests: XCTestCase { func testBuildMenusFallsBackToFirstRoleOnlyWhenCurrentRoleIsNil() { let viewModel = HomeViewModel() let permissions = [ - rolePermission(roleId: 1, permissions: [ + rolePermission(roleCode: "photographer", roleId: 1, permissions: [ permission(id: 1, name: "任务管理", uri: "task_management") ]), - rolePermission(roleId: 2, permissions: [ + rolePermission(roleCode: "editor", roleId: 2, permissions: [ permission(id: 2, name: "消息中心", uri: "message_center") ]) ] - viewModel.buildMenus(from: permissions, currentRoleId: nil) + viewModel.buildMenus(from: permissions, currentRoleCode: nil) XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management"]) } @@ -49,14 +49,14 @@ final class HomeViewModelTests: XCTestCase { func testBuildMenusFlattensPermissionTree() { let viewModel = HomeViewModel() let permissions = [ - rolePermission(roleId: 1, permissions: [ + rolePermission(roleCode: "photographer", roleId: 1, permissions: [ permission(id: 1, name: "任务管理", uri: "task_management", children: [ permission(id: 2, name: "消息中心", uri: "message_center") ]) ]) ] - viewModel.buildMenus(from: permissions, currentRoleId: 1) + viewModel.buildMenus(from: permissions, currentRoleCode: "photographer") XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management", "message_center"]) } @@ -65,7 +65,7 @@ final class HomeViewModelTests: XCTestCase { func testBuildMenusDeduplicatesKnownAndroidAliases() { let viewModel = HomeViewModel() let permissions = [ - rolePermission(roleId: 1, permissions: [ + rolePermission(roleCode: "photographer", roleId: 1, permissions: [ permission(id: 1, name: "项目管理", uri: "pm_manager"), permission(id: 2, name: "项目管理旧入口", uri: "pm"), permission(id: 3, name: "收款码", uri: "payment_code"), @@ -75,7 +75,7 @@ final class HomeViewModelTests: XCTestCase { ]) ] - viewModel.buildMenus(from: permissions, currentRoleId: 1) + viewModel.buildMenus(from: permissions, currentRoleCode: "photographer") XCTAssertEqual(viewModel.menuItems.map(\.uri), ["pm", "deposit_order_detail", "pm_manager", "payment_code"]) } @@ -84,22 +84,22 @@ final class HomeViewModelTests: XCTestCase { func testBuildMenusSortsByPreferredOrder() { let viewModel = HomeViewModel() let permissions = [ - rolePermission(roleId: 1, permissions: [ + rolePermission(roleCode: "photographer", roleId: 1, permissions: [ permission(id: 1, name: "消息中心", uri: "message_center"), permission(id: 2, name: "空间设置", uri: "space_settings"), permission(id: 3, name: "钱包", uri: "wallet") ]) ] - viewModel.buildMenus(from: permissions, currentRoleId: 1) + viewModel.buildMenus(from: permissions, currentRoleCode: "photographer") XCTAssertEqual(viewModel.menuItems.map(\.uri), ["space_settings", "wallet", "message_center"]) } /// 创建角色权限测试实体。 - private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { + private func rolePermission(roleCode: String, roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { RolePermissionResponse( - role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions), + role: RoleInfo(id: roleId, name: "角色\(roleId)", roleCode: roleCode, permission: permissions), scenic: [] ) } diff --git a/suixinkanTests/OperatingArea/OperatingAreaTests.swift b/suixinkanTests/OperatingArea/OperatingAreaTests.swift index 00389d2..75132f5 100644 --- a/suixinkanTests/OperatingArea/OperatingAreaTests.swift +++ b/suixinkanTests/OperatingArea/OperatingAreaTests.swift @@ -156,8 +156,17 @@ private func accountContext(scenicId: Int? = 88, storeId: Int? = 101) -> Account @MainActor private func permissionContext(roleName: String) -> PermissionContext { let context = PermissionContext() - let role = RoleInfo(id: 1, name: roleName, permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")]) - context.replaceRolePermissions([RolePermissionResponse(role: role, scenic: [])], currentRoleId: 1) + let roleCode = AppRoleCode.fromRoleName(roleName)?.rawValue ?? "" + let role = RoleInfo( + id: 1, + name: roleName, + roleCode: roleCode, + permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")] + ) + context.replaceRolePermissions( + [RolePermissionResponse(role: role, scenic: [])], + currentRoleCode: roleCode + ) return context } diff --git a/suixinkanTests/OrdersViewModelTests.swift b/suixinkanTests/OrdersViewModelTests.swift index 5003ac3..94f1ec1 100644 --- a/suixinkanTests/OrdersViewModelTests.swift +++ b/suixinkanTests/OrdersViewModelTests.swift @@ -17,10 +17,10 @@ final class OrdersViewModelTests: XCTestCase { api.storeResponses = [try TestFixture.payload(ListPayload.self, named: "order_v2_success")] let viewModel = OrdersViewModel() - try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil) XCTAssertFalse(viewModel.storeOrders.isEmpty) - try await viewModel.reloadStoreOrders(api: api, scenicId: nil, roleId: nil) + try await viewModel.reloadStoreOrders(api: api, scenicId: nil, appRole: nil) XCTAssertTrue(viewModel.storeOrders.isEmpty) XCTAssertEqual(viewModel.storeTotal, 0) @@ -35,8 +35,8 @@ final class OrdersViewModelTests: XCTestCase { api.storeResponses = [try TestFixture.payload(ListPayload.self, named: "order_v2_success")] let viewModel = OrdersViewModel() - try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) - try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, roleId: nil) + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil) + try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, appRole: nil) XCTAssertEqual(viewModel.storeOrders.count, 2) XCTAssertEqual(viewModel.storeTotal, 2) @@ -58,13 +58,13 @@ final class OrdersViewModelTests: XCTestCase { viewModel.filterStartDate = Self.date("2026-05-01") viewModel.filterEndDate = Self.date("2026-05-31") - try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil) viewModel.selectedStatus = 30 viewModel.searchPhone = " " viewModel.filterStartDate = nil viewModel.filterEndDate = nil - try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: 53) + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: .scenicAdmin) XCTAssertEqual(api.orderListCalls.count, 2) XCTAssertEqual(api.orderListCalls[0].page, 1) diff --git a/suixinkanTests/SaleUserQrParserTests.swift b/suixinkanTests/SaleUserQrParserTests.swift new file mode 100644 index 0000000..c726ecc --- /dev/null +++ b/suixinkanTests/SaleUserQrParserTests.swift @@ -0,0 +1,42 @@ +// +// SaleUserQrParserTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/29. +// + +import XCTest +@testable import suixinkan + +final class SaleUserQrParserTests: XCTestCase { + /// 测试纯数字二维码解析。 + func testParsePlainNumber() { + XCTAssertEqual(SaleUserQrParser.parseSaleUserId("12345"), 12345) + } + + /// 测试历史 JSON 格式解析。 + func testParseLegacyJSONWithoutMethod() { + let json = #"{"sale_user_id":88}"# + XCTAssertEqual(SaleUserQrParser.parseSaleUserId(json), 88) + } + + /// 测试标准 sale_user_bind 二维码。 + func testParseStandardSaleUserBindQR() { + let json = #"{"method":"sale_user_bind","sale_user_id":2}"# + XCTAssertEqual(SaleUserQrParser.parseSaleUserId(json), 2) + XCTAssertTrue(SaleUserQrParser.isSaleUserBindQR(json)) + } + + /// 测试 URL query 中的 sale_user_id。 + func testParseFromURLQuery() { + let url = "https://example.com/bind?sale_user_id=99" + XCTAssertEqual(SaleUserQrParser.parseSaleUserId(url), 99) + } + + /// 测试无效内容返回 nil。 + func testParseInvalidContentReturnsNil() { + XCTAssertNil(SaleUserQrParser.parseSaleUserId("not-a-code")) + XCTAssertNil(SaleUserQrParser.parseSaleUserId("")) + XCTAssertFalse(SaleUserQrParser.isSaleUserBindQR("12345")) + } +} diff --git a/suixinkanTests/ScenicSelectionViewModelTests.swift b/suixinkanTests/ScenicSelectionViewModelTests.swift index 929ce55..ab9ee57 100644 --- a/suixinkanTests/ScenicSelectionViewModelTests.swift +++ b/suixinkanTests/ScenicSelectionViewModelTests.swift @@ -101,7 +101,9 @@ final class ScenicSelectionViewModelTests: XCTestCase { currentScenicId: 1, currentStoreId: 10 ) - permissionContext.replaceRolePermissions([RolePermissionResponse(role: RoleInfo(id: 7, name: "运营"), scenic: [])]) + permissionContext.replaceRolePermissions([ + RolePermissionResponse(role: RoleInfo(id: 7, name: "运营", roleCode: "scenic_operation"), scenic: []) + ], currentRoleCode: "scenic_operation") ScenicSelectionViewModel().select( scenicId: 2, @@ -114,6 +116,6 @@ final class ScenicSelectionViewModelTests: XCTestCase { XCTAssertEqual(accountContext.currentStore?.id, 20) XCTAssertEqual(snapshotStore.load()?.currentScenicId, 2) XCTAssertEqual(snapshotStore.load()?.currentStoreId, 20) - XCTAssertEqual(snapshotStore.load()?.currentRoleId, 7) + XCTAssertEqual(snapshotStore.load()?.currentRoleCode, "scenic_operation") } } diff --git a/suixinkanTests/StatisticsViewModelTests.swift b/suixinkanTests/StatisticsViewModelTests.swift index ffd8742..ecd25cc 100644 --- a/suixinkanTests/StatisticsViewModelTests.swift +++ b/suixinkanTests/StatisticsViewModelTests.swift @@ -18,7 +18,7 @@ final class StatisticsViewModelTests: XCTestCase { api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] let viewModel = StatisticsViewModel() - try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + try await viewModel.reload(api: api, scenicId: 88, appRole: nil) XCTAssertEqual(viewModel.summary.orderCount, 18) XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"]) @@ -44,11 +44,11 @@ final class StatisticsViewModelTests: XCTestCase { ] let viewModel = StatisticsViewModel() - try await viewModel.reload(api: api, scenicId: 88, roleId: nil) - try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + try await viewModel.reload(api: api, scenicId: 88, appRole: nil) + try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil) XCTAssertEqual(viewModel.dailyItems.count, 3) - try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, roleId: nil) + try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, appRole: nil) XCTAssertEqual(viewModel.selectedPeriod, .sevenDays) XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"]) @@ -70,8 +70,8 @@ final class StatisticsViewModelTests: XCTestCase { ] let viewModel = StatisticsViewModel() - try await viewModel.reload(api: api, scenicId: 88, roleId: nil) - try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + try await viewModel.reload(api: api, scenicId: 88, appRole: nil) + try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil) XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23", "2026-05-22", "2026-05-21"]) XCTAssertFalse(viewModel.hasMore) @@ -86,11 +86,11 @@ final class StatisticsViewModelTests: XCTestCase { api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] let viewModel = StatisticsViewModel() - try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + try await viewModel.reload(api: api, scenicId: 88, appRole: nil) api.nextDailyError = APIError.httpStatus(500, "server error") do { - try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil) XCTFail("Expected load more to fail") } catch APIError.httpStatus(let statusCode, _) { XCTAssertEqual(statusCode, 500) @@ -110,10 +110,10 @@ final class StatisticsViewModelTests: XCTestCase { api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] let viewModel = StatisticsViewModel() - try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + try await viewModel.reload(api: api, scenicId: 88, appRole: nil) XCTAssertEqual(viewModel.summary.orderCount, 18) - try await viewModel.reload(api: api, scenicId: nil, roleId: nil) + try await viewModel.reload(api: api, scenicId: nil, appRole: nil) XCTAssertEqual(api.summaryCalls.count, 1) XCTAssertEqual(viewModel.summary.orderCount, 0) diff --git a/功能同步Checklist.md b/功能同步Checklist.md index ac21e7a..f77b2f1 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -1,6 +1,6 @@ # 功能同步 Checklist -更新时间:2026-06-24 +更新时间:2026-06-29 ## 状态说明 @@ -59,6 +59,7 @@ | 完成 | 历史拍摄 | 多点位历史拍摄项目、拍摄点和媒体展示。 | 仅展示已有媒体,不做上传、删除、下载或编辑。 | | 完成 | 任务上传 | 多点旅拍订单选择已核销打卡点、云盘素材、本地图片/视频 OSS 上传并提交素材。 | 仅 `orderType == 19` 展示。 | | 完成 | 视频预告/尾片上传 | 进入订单尾片引导页,并复用相册预览上传。 | 没有独立订单尾片接口,暂不做尾片审核进度。 | +| 完成 | 订单来源/带客单绑定 | orderType 11/14 订单卡片展示「订单来源」Sheet;支持选择获客员与带客单绑定;全页 `ReferralOrderSelectView`;退款后同步带客单状态。 | 依赖 `CooperationOrderAPI` 与首页合作订单模块。 | ## 数据 Tab @@ -110,6 +111,7 @@ | 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | | 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | | 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 | +| 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 | | 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 | ## 旧工程能力尚未系统迁移