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