diff --git a/suixinkan/App/State/AccountContext.swift b/suixinkan/App/State/AccountContext.swift index 089935b..370c8d7 100644 --- a/suixinkan/App/State/AccountContext.swift +++ b/suixinkan/App/State/AccountContext.swift @@ -63,11 +63,22 @@ struct BusinessScope: Codable, Equatable, Identifiable { final class AccountContext: ObservableObject { @Published private(set) var profile: AccountProfile? @Published private(set) var accountType: String? + @Published private(set) var businessUserId: Int? + /// 当前账号在「我的」页面展示的标题,来源于登录/切换时选中的账号名称。 + @Published private(set) var currentAccountDisplayTitle: String? @Published private(set) var scenicScopes: [BusinessScope] = [] @Published private(set) var storeScopes: [BusinessScope] = [] @Published var currentScenic: BusinessScope? @Published var currentStore: BusinessScope? + /// 当前账号标识,与 AccountSwitchAccount.id 规则一致。 + var currentAccountIdentifier: String? { + guard let businessUserId, businessUserId > 0 else { return nil } + let type = accountType?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !type.isEmpty else { return nil } + return "\(type)_\(businessUserId)" + } + /// 当前账号缓存前缀,与 Android 的账号级缓存作用域保持一致。 var accountCachePrefix: String? { let userId = profile?.userId.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -77,9 +88,20 @@ final class AccountContext: ObservableObject { } /// 应用登录成功后写入账号资料。 - func applyLogin(profile: AccountProfile? = nil, accountType: String? = nil) { + func applyLogin( + profile: AccountProfile? = nil, + accountType: String? = nil, + businessUserId: Int? = nil, + displayTitle: String? = nil + ) { self.profile = profile self.accountType = accountType?.trimmingCharacters(in: .whitespacesAndNewlines) + if let businessUserId, businessUserId > 0 { + self.businessUserId = businessUserId + } + if let displayTitle = normalizedText(displayTitle) { + currentAccountDisplayTitle = displayTitle + } } /// 替换当前账号资料,通常用于用户信息刷新后同步全局展示。 @@ -92,10 +114,29 @@ final class AccountContext: ObservableObject { scenic: [BusinessScope], stores: [BusinessScope], currentScenicId: Int? = nil, - currentStoreId: Int? = nil + currentStoreId: Int? = nil, + explicitSelection: Bool = false ) { scenicScopes = scenic storeScopes = stores + + if explicitSelection { + currentScenic = currentScenicId.flatMap { id in + scenic.first { $0.id == id } + } ?? scenic.first + if let storeId = currentStoreId, storeId > 0 { + currentStore = resolvedStore( + in: stores, + currentScenic: currentScenic, + preferredStoreId: storeId, + allowFallbackToFirst: false + ) + } else { + currentStore = nil + } + return + } + currentScenic = currentScenicId.flatMap { id in scenic.first { $0.id == id } } ?? currentScenic.flatMap { current in @@ -104,7 +145,8 @@ final class AccountContext: ObservableObject { currentStore = resolvedStore( in: stores, currentScenic: currentScenic, - preferredStoreId: currentStoreId ?? currentStore?.id + preferredStoreId: currentStoreId ?? currentStore?.id, + allowFallbackToFirst: true ) } @@ -115,7 +157,8 @@ final class AccountContext: ObservableObject { currentStore = resolvedStore( in: storeScopes, currentScenic: scenic, - preferredStoreId: currentStore?.id + preferredStoreId: currentStore?.id, + allowFallbackToFirst: true ) } @@ -134,6 +177,8 @@ final class AccountContext: ObservableObject { func reset() { profile = nil accountType = nil + businessUserId = nil + currentAccountDisplayTitle = nil scenicScopes = [] storeScopes = [] currentScenic = nil @@ -144,7 +189,8 @@ final class AccountContext: ObservableObject { private func resolvedStore( in stores: [BusinessScope], currentScenic: BusinessScope?, - preferredStoreId: Int? + preferredStoreId: Int?, + allowFallbackToFirst: Bool ) -> BusinessScope? { let scenicId = currentScenic?.id let storesForScenic = stores.filter { store in @@ -155,6 +201,13 @@ final class AccountContext: ObservableObject { let store = storesForScenic.first(where: { $0.id == preferredStoreId }) { return store } + guard allowFallbackToFirst else { return nil } return storesForScenic.first } + + /// 返回去空白后的非空文本。 + private func normalizedText(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } } diff --git a/suixinkan/App/State/AccountContextLoader.swift b/suixinkan/App/State/AccountContextLoader.swift index 4efaffa..8e4e534 100644 --- a/suixinkan/App/State/AccountContextLoader.swift +++ b/suixinkan/App/State/AccountContextLoader.swift @@ -26,7 +26,8 @@ struct AccountContextLoader { cachedCurrentRoleCode: String? = nil, cachedLegacyRoleId: Int? = nil, cachedCurrentScenicId: Int? = nil, - cachedCurrentStoreId: Int? = nil + cachedCurrentStoreId: Int? = nil, + explicitScopeSelection: Bool = false ) async throws { try await restorePermissions( permissionContext: permissionContext, @@ -35,7 +36,8 @@ struct AccountContextLoader { cachedCurrentRoleCode: cachedCurrentRoleCode, cachedLegacyRoleId: cachedLegacyRoleId, cachedCurrentScenicId: cachedCurrentScenicId, - cachedCurrentStoreId: cachedCurrentStoreId + cachedCurrentStoreId: cachedCurrentStoreId, + explicitScopeSelection: explicitScopeSelection ) try await refreshSupplementalContext( accountContext: accountContext, @@ -43,7 +45,8 @@ struct AccountContextLoader { profileAPI: profileAPI, accountContextAPI: accountContextAPI, cachedCurrentScenicId: cachedCurrentScenicId, - cachedCurrentStoreId: cachedCurrentStoreId + cachedCurrentStoreId: cachedCurrentStoreId, + explicitScopeSelection: explicitScopeSelection ) } @@ -55,7 +58,8 @@ struct AccountContextLoader { cachedCurrentRoleCode: String? = nil, cachedLegacyRoleId: Int? = nil, cachedCurrentScenicId: Int? = nil, - cachedCurrentStoreId: Int? = nil + cachedCurrentStoreId: Int? = nil, + explicitScopeSelection: Bool = false ) async throws { let rolePermissions = try await accountContextAPI.rolePermissions() permissionContext.replaceRolePermissions( @@ -70,7 +74,8 @@ struct AccountContextLoader { scenic: roleScenicScopes, stores: accountContext.storeScopes, currentScenicId: cachedCurrentScenicId, - currentStoreId: cachedCurrentStoreId + currentStoreId: cachedCurrentStoreId, + explicitSelection: explicitScopeSelection ) } @@ -81,7 +86,8 @@ struct AccountContextLoader { profileAPI: UserProfileServing, accountContextAPI: AccountContextServing, cachedCurrentScenicId: Int? = nil, - cachedCurrentStoreId: Int? = nil + cachedCurrentStoreId: Int? = nil, + explicitScopeSelection: Bool = false ) async throws { let userInfo = try await profileAPI.userInfo() let scenicResponse = await loadScenicList( @@ -94,19 +100,57 @@ struct AccountContextLoader { permissionContext: permissionContext, scenicResponse: scenicResponse ) - let storeScopes = storesResponse?.list.map { store in + let fetchedStoreScopes = storesResponse?.list.map { store in BusinessScope(id: store.id, name: store.name, kind: .store, parentScenicId: store.scenicId) } ?? [] + // 门店列表接口失败或为空时,保留登录/切换时已写入的门店作用域,避免门店账号展示回退成景区名。 + let storeScopes = fetchedStoreScopes.isEmpty ? accountContext.storeScopes : fetchedStoreScopes accountContext.replaceProfile(profile) accountContext.replaceScopes( scenic: scenicScopes, stores: storeScopes, - currentScenicId: cachedCurrentScenicId ?? accountContext.currentScenic?.id, - currentStoreId: cachedCurrentStoreId ?? accountContext.currentStore?.id + currentScenicId: resolvedScenicId( + cachedCurrentScenicId: cachedCurrentScenicId, + accountContext: accountContext, + explicitScopeSelection: explicitScopeSelection + ), + currentStoreId: resolvedStoreId( + cachedCurrentStoreId: cachedCurrentStoreId, + accountContext: accountContext, + explicitScopeSelection: explicitScopeSelection + ), + explicitSelection: explicitScopeSelection ) } + /// 解析刷新时应使用的景区 ID。 + private func resolvedScenicId( + cachedCurrentScenicId: Int?, + accountContext: AccountContext, + explicitScopeSelection: Bool + ) -> Int? { + if explicitScopeSelection { + return cachedCurrentScenicId ?? accountContext.currentScenic?.id + } + return cachedCurrentScenicId ?? accountContext.currentScenic?.id + } + + /// 解析刷新时应使用的门店 ID;景区账号在显式选择模式下必须清空门店。 + private func resolvedStoreId( + cachedCurrentStoreId: Int?, + accountContext: AccountContext, + explicitScopeSelection: Bool + ) -> Int? { + if explicitScopeSelection { + if accountContext.accountType == V9ScenicUser.accountTypeValue { + return nil + } + return cachedCurrentStoreId + } + return cachedCurrentStoreId ?? accountContext.currentStore?.id + } + /// 景区列表接口失败时,从角色权限中的景区数据去重构造兜底列表。 private func loadScenicList( accountContextAPI: AccountContextServing, diff --git a/suixinkan/App/State/AuthSessionCoordinator.swift b/suixinkan/App/State/AuthSessionCoordinator.swift index 411bae4..ef99d7b 100644 --- a/suixinkan/App/State/AuthSessionCoordinator.swift +++ b/suixinkan/App/State/AuthSessionCoordinator.swift @@ -52,7 +52,8 @@ final class AuthSessionCoordinator { accountContext: AccountContext, permissionContext: PermissionContext, profileAPI: UserProfileServing, - accountContextAPI: AccountContextServing + accountContextAPI: AccountContextServing, + selectedAccount: AccountSwitchAccount? = nil ) async throws { let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) try tokenStore.save(token) @@ -62,9 +63,22 @@ final class AuthSessionCoordinator { let profile = response.primaryProfile let scenicScopes = response.scenicScopes let storeScopes = response.storeScopes - let identity = response.currentAccountIdentity - accountContext.applyLogin(profile: profile, accountType: identity?.accountType) - accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes) + let identity = resolvedIdentity(from: response, selectedAccount: selectedAccount) + let scopeSelection = selectedAccount?.preferredScopeSelection ?? response.preferredScopeSelection + let displayTitle = nonEmpty(selectedAccount?.title) ?? response.currentAccountDisplayTitle + accountContext.applyLogin( + profile: profile, + accountType: identity?.accountType, + businessUserId: identity?.businessUserId, + displayTitle: displayTitle + ) + accountContext.replaceScopes( + scenic: scenicScopes, + stores: storeScopes, + currentScenicId: scopeSelection.scenicId, + currentStoreId: scopeSelection.storeId, + explicitSelection: true + ) appSession.beginRestoring(token: token) do { @@ -74,8 +88,9 @@ final class AuthSessionCoordinator { profileAPI: profileAPI, accountContextAPI: accountContextAPI, cachedCurrentRoleCode: response.currentAccountRoleCode, - cachedCurrentScenicId: accountContext.currentScenic?.id, - cachedCurrentStoreId: accountContext.currentStore?.id + cachedCurrentScenicId: scopeSelection.scenicId, + cachedCurrentStoreId: scopeSelection.storeId, + explicitScopeSelection: true ) } catch { if APIError.isAuthenticationExpired(error) { @@ -153,6 +168,7 @@ final class AuthSessionCoordinator { profile: profile, accountType: accountType, businessUserId: businessUserId, + currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle, currentRoleCode: permissionContext?.currentAppRole?.rawValue ?? currentRoleCode, scenicScopes: accountContext.scenicScopes, storeScopes: accountContext.storeScopes, @@ -162,6 +178,17 @@ final class AuthSessionCoordinator { ) } + /// 优先使用用户明确选择的账号身份,避免 set-user 响应仍携带旧 is_current 时解析错误。 + private func resolvedIdentity( + from response: V9AuthResponse, + selectedAccount: AccountSwitchAccount? + ) -> (accountType: String, businessUserId: Int)? { + if let selectedAccount, selectedAccount.businessUserId > 0 { + return (selectedAccount.accountType, selectedAccount.businessUserId) + } + return response.currentAccountIdentity.map { ($0.accountType, $0.businessUserId) } + } + /// 将用户资料接口响应转换为全局账号资料。 private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile { AccountProfile( @@ -185,27 +212,3 @@ struct LoginPreferences: Equatable { let privacyAgreementAccepted: Bool } -private extension V9AuthResponse { - /// 当前账号身份实体,表示登录后实际选中的业务账号。 - struct CurrentAccountIdentity { - let accountType: String - let businessUserId: Int - } - - /// 提取当前账号身份,优先使用后端标记的 isCurrent 账号。 - var currentAccountIdentity: CurrentAccountIdentity? { - if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { - return CurrentAccountIdentity( - accountType: storeUser.accountType, - businessUserId: storeUser.businessUserId - ) - } - if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first { - return CurrentAccountIdentity( - accountType: scenicUser.accountType, - businessUserId: scenicUser.businessUserId - ) - } - return nil - } -} diff --git a/suixinkan/App/State/SessionBootstrapper.swift b/suixinkan/App/State/SessionBootstrapper.swift index 411bf94..be1988f 100644 --- a/suixinkan/App/State/SessionBootstrapper.swift +++ b/suixinkan/App/State/SessionBootstrapper.swift @@ -89,12 +89,18 @@ final class SessionBootstrapper { /// 将缓存快照恢复到账号上下文。 private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? { guard let snapshot = snapshotStore.load() else { return nil } - accountContext.applyLogin(profile: snapshot.profile, accountType: snapshot.accountType) + accountContext.applyLogin( + profile: snapshot.profile, + accountType: snapshot.accountType, + businessUserId: snapshot.businessUserId, + displayTitle: snapshot.currentAccountDisplayTitle + ) accountContext.replaceScopes( scenic: snapshot.scenicScopes, stores: snapshot.storeScopes, currentScenicId: snapshot.currentScenicId, - currentStoreId: snapshot.currentStoreId + currentStoreId: snapshot.currentStoreId, + explicitSelection: true ) return snapshot } @@ -198,6 +204,7 @@ final class SessionBootstrapper { profile: accountContext.profile, accountType: accountContext.accountType ?? existing?.accountType, businessUserId: existing?.businessUserId, + currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle ?? existing?.currentAccountDisplayTitle, currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode, scenicScopes: accountContext.scenicScopes, storeScopes: accountContext.storeScopes, diff --git a/suixinkan/Core/Storage/AccountSnapshotStore.swift b/suixinkan/Core/Storage/AccountSnapshotStore.swift index 7a1f54e..4d99660 100644 --- a/suixinkan/Core/Storage/AccountSnapshotStore.swift +++ b/suixinkan/Core/Storage/AccountSnapshotStore.swift @@ -13,6 +13,7 @@ struct AccountSnapshot: Codable, Equatable { var profile: AccountProfile? var accountType: String? var businessUserId: Int? + var currentAccountDisplayTitle: String? var currentRoleCode: String? /// 旧版快照字段,仅用于解码与一次性迁移。 var currentRoleId: Int? @@ -26,6 +27,7 @@ struct AccountSnapshot: Codable, Equatable { profile: AccountProfile? = nil, accountType: String? = nil, businessUserId: Int? = nil, + currentAccountDisplayTitle: String? = nil, currentRoleCode: String? = nil, currentRoleId: Int? = nil, scenicScopes: [BusinessScope] = [], @@ -36,6 +38,7 @@ struct AccountSnapshot: Codable, Equatable { self.profile = profile self.accountType = accountType self.businessUserId = businessUserId + self.currentAccountDisplayTitle = currentAccountDisplayTitle self.currentRoleCode = currentRoleCode self.currentRoleId = currentRoleId self.scenicScopes = scenicScopes @@ -117,6 +120,7 @@ final class AccountSnapshotStore { profile: accountContext.profile ?? existing?.profile, accountType: accountContext.accountType ?? existing?.accountType, businessUserId: existing?.businessUserId, + currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle ?? existing?.currentAccountDisplayTitle, currentRoleCode: currentRoleCode ?? existing?.currentRoleCode, scenicScopes: accountContext.scenicScopes, storeScopes: accountContext.storeScopes, diff --git a/suixinkan/Features/Auth/Models/AuthModels.swift b/suixinkan/Features/Auth/Models/AuthModels.swift index b23c17e..45f232b 100644 --- a/suixinkan/Features/Auth/Models/AuthModels.swift +++ b/suixinkan/Features/Auth/Models/AuthModels.swift @@ -76,6 +76,14 @@ struct AccountSwitchAccount: Identifiable, Hashable { } return SetUserRequest(ssUserId: businessUserId) } + + /// 当前账号对应的景区和门店 ID,用于切换后校准业务作用域。 + var preferredScopeSelection: (scenicId: Int?, storeId: Int?) { + if isStoreUser { + return (scenicId, storeId) + } + return (scenicId, nil) + } } /// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。 @@ -100,9 +108,9 @@ struct V9AuthResponse: Decodable, Equatable { scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() } } - /// 提取当前或首个账号作为全局账号资料。 + /// 提取当前或首个账号作为全局账号资料,优先使用 isCurrent 标记的账号。 var primaryProfile: AccountProfile? { - if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { + if let storeUser = resolvedCurrentStoreUser { return AccountProfile( userId: String(storeUser.userId), displayName: storeUser.displayName, @@ -111,7 +119,7 @@ struct V9AuthResponse: Decodable, Equatable { ) } - if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first { + if let scenicUser = resolvedCurrentScenicUser { return AccountProfile( userId: String(scenicUser.userId), displayName: scenicUser.displayName, @@ -123,6 +131,60 @@ struct V9AuthResponse: Decodable, Equatable { return nil } + /// 当前生效账号的类型和业务 ID,用于登录/切换后写入账号上下文。 + var currentAccountIdentity: (accountType: String, businessUserId: Int)? { + if let storeUser = resolvedCurrentStoreUser { + return (storeUser.accountType, storeUser.businessUserId) + } + if let scenicUser = resolvedCurrentScenicUser { + return (scenicUser.accountType, scenicUser.businessUserId) + } + return nil + } + + /// 当前生效账号对应的景区和门店 ID,用于登录/切换后校准业务作用域。 + var preferredScopeSelection: (scenicId: Int?, storeId: Int?) { + if let storeUser = resolvedCurrentStoreUser { + return ( + storeUser.scenicId > 0 ? storeUser.scenicId : nil, + storeUser.storeId > 0 ? storeUser.storeId : nil + ) + } + if let scenicUser = resolvedCurrentScenicUser { + return (scenicUser.scenicId > 0 ? scenicUser.scenicId : nil, nil) + } + return (nil, nil) + } + + /// 当前生效账号在「我的」页面展示的标题。 + var currentAccountDisplayTitle: String? { + if let storeUser = resolvedCurrentStoreUser { + return storeUser.displayName.nonEmpty + } + if let scenicUser = resolvedCurrentScenicUser { + return scenicUser.displayName.nonEmpty + } + return nil + } + + /// 解析当前门店账号:isCurrent 优先,且在有景区 isCurrent 时不回退到其他门店。 + private var resolvedCurrentStoreUser: V9StoreUser? { + if let current = storeUsers.first(where: \.isCurrent) { + return current + } + guard !scenicUsers.contains(where: \.isCurrent) else { return nil } + return storeUsers.first + } + + /// 解析当前景区账号:isCurrent 优先,且在有门店 isCurrent 时不回退到其他景区。 + private var resolvedCurrentScenicUser: V9ScenicUser? { + if let current = scenicUsers.first(where: \.isCurrent) { + return current + } + guard !storeUsers.contains(where: \.isCurrent) else { return nil } + return scenicUsers.first + } + /// 提取登录响应中的景区作用域,并按当前账号优先排序。 var scenicScopes: [BusinessScope] { var seen = Set() @@ -159,10 +221,10 @@ struct V9AuthResponse: Decodable, Equatable { /// 当前账号的 app_role_code,用于登录后初始角色匹配。 var currentAccountRoleCode: String? { - if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { + if let storeUser = resolvedCurrentStoreUser { return nonEmpty(storeUser.resolvedAppRoleCode) } - if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first { + if let scenicUser = resolvedCurrentScenicUser { return nonEmpty(scenicUser.resolvedAppRoleCode) } return nil diff --git a/suixinkan/Features/Auth/Views/LoginView.swift b/suixinkan/Features/Auth/Views/LoginView.swift index 9d4cd5b..ee6d160 100644 --- a/suixinkan/Features/Auth/Views/LoginView.swift +++ b/suixinkan/Features/Auth/Views/LoginView.swift @@ -179,7 +179,7 @@ struct LoginView: View { do { try await globalLoading.withLoading(message: "账号切换中...") { let response = try await viewModel.selectAccount(account, authAPI: authAPI) - await completeLogin(with: response) + await completeLogin(with: response, selectedAccount: account) } } catch is CancellationError { // Keep cancellation silent; the current task was abandoned by SwiftUI. @@ -190,7 +190,7 @@ struct LoginView: View { } /// 将最终登录响应写入全局会话和账号上下文。 - private func completeLogin(with response: V9AuthResponse) async { + private func completeLogin(with response: V9AuthResponse, selectedAccount: AccountSwitchAccount? = nil) async { do { try await authSessionCoordinator.completeLogin( with: response, @@ -200,7 +200,8 @@ struct LoginView: View { accountContext: accountContext, permissionContext: permissionContext, profileAPI: profileAPI, - accountContextAPI: accountContextAPI + accountContextAPI: accountContextAPI, + selectedAccount: selectedAccount ) } catch { toastCenter.show(error.localizedDescription) diff --git a/suixinkan/Features/Home/AllFunctions.md b/suixinkan/Features/Home/AllFunctions.md index 8730e53..a38f318 100644 --- a/suixinkan/Features/Home/AllFunctions.md +++ b/suixinkan/Features/Home/AllFunctions.md @@ -80,7 +80,7 @@ allFunctions = 顶层权限 .映射为 HomeMenuItem ``` -`androidHomeMenuURIs` 与 Android `Constants.menuList` 登记 URI 一致。已登记且会出现在「全部功能」页的 URI 包括 `location_report`、`photographer_report`(举报摄影师)、`wallet`、`message_center` 等。 +`androidHomeMenuURIs` 与 Android `Constants.menuList` 登记 URI 一致。已登记且会出现在「全部功能」页的 URI 包括 `location_report`、`report_photographer`(举报摄影师)、`wallet`、`message_center` 等。 以下典型 URI **不会**出现在「全部功能」页,即使接口返回了顶层权限: diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index be9e59b..58d1c25 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -62,4 +62,4 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 -`photographer_report` 已由 `Features/WildPhotographerReport` 接管。 +`report_photographer` 已由 `Features/WildPhotographerReport` 接管。 diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 156ddd8..62232a4 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -46,7 +46,7 @@ enum HomeMenuRouter { "pm_manager": "店铺项目管理", "project_edit": "项目管理", "location_report": "位置上报", - "photographer_report": "举报摄影师", + "report_photographer": "举报摄影师", "registration_invitation": "注册邀请", "store": "店铺管理", "fly": "飞行管理", @@ -132,7 +132,7 @@ enum HomeMenuRouter { return .destination(.locationReport) case "location_report_history": return .destination(.locationReportHistory) - case "photographer_report": + case "report_photographer": return .destination(.photographerReport) case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": return .destination(.depositOrders) @@ -219,7 +219,7 @@ enum HomeMenuRouter { "pm_manager", "project_edit", "location_report", - "photographer_report", + "report_photographer", "registration_invitation", "photographer_invite", "invite_record", @@ -298,7 +298,7 @@ enum HomeMenuRouter { return "注册邀请" case "location_report": return "位置上报" - case "photographer_report": + case "report_photographer": return "举报摄影师" case "pm", "project_edit": return "项目管理" diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index 4c4db78..c16d673 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -30,7 +30,7 @@ struct HomeCommonMenuStore { "travel_album", "pm", "location_report", - "photographer_report", + "report_photographer", "registration_invitation", "store", "fly", diff --git a/suixinkan/Features/Home/Views/HomeIconCatalog.swift b/suixinkan/Features/Home/Views/HomeIconCatalog.swift index 1eebf90..3faa94f 100644 --- a/suixinkan/Features/Home/Views/HomeIconCatalog.swift +++ b/suixinkan/Features/Home/Views/HomeIconCatalog.swift @@ -54,7 +54,7 @@ enum HomeIconCatalog { "circle.grid.2x2.fill" case "location_report", "location_report_history": "mappin.circle.fill" - case "photographer_report": + case "report_photographer": "shield.fill" case "registration_invitation", "photographer_invite": "envelope.open.fill" diff --git a/suixinkan/Features/Profile/Profile.md b/suixinkan/Features/Profile/Profile.md index 70e4387..4e773e7 100644 --- a/suixinkan/Features/Profile/Profile.md +++ b/suixinkan/Features/Profile/Profile.md @@ -125,3 +125,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口, - 无实名认证信息时:点击去实名认证。 - 账号切换失败只展示 Toast,不清空登录态。 - 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。 +- 「当前账号」展示规则: + - 门店账号(`store_user`)展示门店名称,不回退到景区名称。 + - 景区账号(`scenic_user`)展示景区名称。 + - 展示标题在登录/切换成功时写入 `AccountContext.currentAccountDisplayTitle`,切换账号时必须同步更新,不能沿用上一次账号名称。 diff --git a/suixinkan/Features/Profile/Views/AccountSwitchView.swift b/suixinkan/Features/Profile/Views/AccountSwitchView.swift index 50b7b52..ba3de96 100644 --- a/suixinkan/Features/Profile/Views/AccountSwitchView.swift +++ b/suixinkan/Features/Profile/Views/AccountSwitchView.swift @@ -212,7 +212,8 @@ struct AccountSwitchView: View { accountContext: accountContext, permissionContext: permissionContext, profileAPI: profileAPI, - accountContextAPI: accountContextAPI + accountContextAPI: accountContextAPI, + selectedAccount: account ) } appRouter.reset() @@ -226,14 +227,7 @@ struct AccountSwitchView: View { } private var currentAccountId: String? { - guard let current = accountContext.profile else { return nil } - if let store = accountContext.currentStore { - return "\(V9StoreUser.accountTypeValue)_\(store.id)" - } - if let scenic = accountContext.currentScenic { - return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)" - } - return current.userId.isEmpty ? nil : current.userId + accountContext.currentAccountIdentifier } /// 判断账号是否为当前已选账号。 diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift index bc0dfe5..681aeef 100644 --- a/suixinkan/Features/Profile/Views/ProfileView.swift +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -494,20 +494,51 @@ struct ProfileView: View { } private var currentAccountDisplayName: String { + nonEmpty(accountContext.currentAccountDisplayTitle) + ?? (isStoreAccountContext ? resolvedStoreAccountName : resolvedScenicAccountName) + } + + /// 门店账号展示名:优先门店名称,不回退到景区名称。 + private var resolvedStoreAccountName: String { nonEmpty(accountContext.currentStore?.name) - ?? nonEmpty(accountContext.currentScenic?.name) - ?? accountContext.profile?.displayName + ?? nonEmpty(matchedStoreScope?.name) + ?? nonEmpty(accountContext.profile?.displayName) ?? viewModel.displayNickname } + /// 景区账号展示名:优先景区名称。 + private var resolvedScenicAccountName: String { + nonEmpty(accountContext.currentScenic?.name) + ?? nonEmpty(accountContext.profile?.displayName) + ?? viewModel.displayNickname + } + + /// 当前上下文是否应按门店账号展示。 + private var isStoreAccountContext: Bool { + accountContext.accountType == V9StoreUser.accountTypeValue + } + + /// 从门店作用域中匹配当前门店,用于 currentStore 尚未就绪时的展示兜底。 + private var matchedStoreScope: BusinessScope? { + if let currentStore = accountContext.currentStore { + return currentStore + } + guard accountContext.accountType == V9StoreUser.accountTypeValue else { return nil } + if accountContext.storeScopes.count == 1 { + return accountContext.storeScopes.first + } + return nil + } + private var currentAccountTypeText: String { - if accountContext.currentStore != nil { + switch accountContext.accountType { + case V9StoreUser.accountTypeValue: return "门店账号" - } - if accountContext.currentScenic != nil { + case V9ScenicUser.accountTypeValue: return "景区账号" + default: + return nonEmpty(viewModel.userInfo?.roleName) ?? "账号" } - return nonEmpty(viewModel.userInfo?.roleName) ?? "账号" } private var nicknameBinding: Binding { diff --git a/suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift b/suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift index 4997263..8c81af0 100644 --- a/suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift +++ b/suixinkan/Features/ProfileSpace/Views/ProfileSpaceView.swift @@ -576,6 +576,7 @@ struct ProfileSpaceView: View { profile: profile, accountType: accountContext.accountType ?? existing?.accountType, businessUserId: existing?.businessUserId, + currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle ?? existing?.currentAccountDisplayTitle, currentRoleCode: existing?.currentRoleCode, scenicScopes: accountContext.scenicScopes, storeScopes: accountContext.storeScopes, diff --git a/suixinkan/Features/WildPhotographerReport/WildPhotographerReport.md b/suixinkan/Features/WildPhotographerReport/WildPhotographerReport.md index 9c725ac..9acb4cd 100644 --- a/suixinkan/Features/WildPhotographerReport/WildPhotographerReport.md +++ b/suixinkan/Features/WildPhotographerReport/WildPhotographerReport.md @@ -2,7 +2,7 @@ ## 模块职责 -WildPhotographerReport 模块负责首页 `photographer_report` 入口,为摄影师和景区运营提供「举报摄影师」能力。 +WildPhotographerReport 模块负责首页 `report_photographer` 入口,为摄影师和景区运营提供「举报摄影师」能力。 本模块与 `LocationReport`(位置上报)无关:前者是用户举报疑似打野/未戴工牌摄影师,后者是摄影师 GPS 打卡。 @@ -49,7 +49,7 @@ flowchart TD ## 路由 -- 权限 URI:`photographer_report` +- 权限 URI:`report_photographer` - 首页路由:`HomeRoute.photographerReport` - 入口不固定置顶,依赖权限下发或用户手动添加到常用应用(`HomeCommonMenuStore.androidHomeMenuURIs` 白名单已包含该 URI)。 @@ -57,10 +57,10 @@ flowchart TD | 模块 | URI | 用途 | 后端 | | --- | --- | --- | --- | -| WildPhotographerReport | `photographer_report` | 举报摄影师 | Mock | +| WildPhotographerReport | `report_photographer` | 举报摄影师 | Mock | | LocationReport | `location_report` | 位置上报 | 真实 API | ## 测试要求 - `WildPhotographerReportTests`:Home/Submit/List ViewModel 校验与提交成功路径。 -- `HomeMenuRouterTests`:`photographer_report` 路由映射。 +- `HomeMenuRouterTests`:`report_photographer` 路由映射。 diff --git a/suixinkanTests/AccountContextLoaderTests.swift b/suixinkanTests/AccountContextLoaderTests.swift index dc0568a..36c2d48 100644 --- a/suixinkanTests/AccountContextLoaderTests.swift +++ b/suixinkanTests/AccountContextLoaderTests.swift @@ -147,6 +147,40 @@ final class AccountContextLoaderTests: XCTestCase { XCTAssertTrue(accountContext.storeScopes.isEmpty) XCTAssertNil(accountContext.currentStore) } + + /// 测试门店接口失败时会保留登录/切换时已写入的门店作用域。 + func testRefreshSupplementalContextPreservesExistingStoreScopesWhenStoreAPIFails() async throws { + let accountContext = AccountContext() + accountContext.applyLogin(accountType: V9StoreUser.accountTypeValue) + accountContext.replaceScopes( + scenic: [BusinessScope(id: 88, name: "东湖景区", kind: .scenic)], + stores: [BusinessScope(id: 66, name: "东湖门店", kind: .store, parentScenicId: 88)], + currentScenicId: 88, + currentStoreId: 66 + ) + let permissionContext = PermissionContext() + let api = MockAccountContextAPI() + api.roles = [ + RolePermissionResponse( + role: RoleInfo(id: 7, name: "运营"), + scenic: [ScenicInfo(id: 88, name: "东湖景区")] + ) + ] + api.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")]) + api.storeError = APIError.networkFailed("门店接口失败") + + try await AccountContextLoader().refreshSupplementalContext( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: MockUserProfileAPI(), + accountContextAPI: api, + cachedCurrentScenicId: 88, + cachedCurrentStoreId: 66 + ) + + XCTAssertEqual(accountContext.storeScopes.map(\.name), ["东湖门店"]) + XCTAssertEqual(accountContext.currentStore?.name, "东湖门店") + } } @MainActor diff --git a/suixinkanTests/AccountContextTests.swift b/suixinkanTests/AccountContextTests.swift index 752f489..ee48691 100644 --- a/suixinkanTests/AccountContextTests.swift +++ b/suixinkanTests/AccountContextTests.swift @@ -46,6 +46,32 @@ final class AccountContextTests: XCTestCase { XCTAssertEqual(context.currentStore?.id, 20) } + /// 测试显式切换景区账号时会清空旧门店,而不是保留上一次门店选择。 + func testReplaceScopesExplicitSelectionClearsStoreForScenicAccount() { + let context = AccountContext() + context.replaceScopes( + scenic: [BusinessScope(id: 128, name: "伊犁那拉提景区-5A", kind: .scenic)], + stores: [BusinessScope(id: 37, name: "随心旅拍", kind: .store, parentScenicId: 128)], + currentScenicId: 128, + currentStoreId: 37, + explicitSelection: true + ) + XCTAssertEqual(context.currentStore?.name, "随心旅拍") + + context.applyLogin(accountType: V9ScenicUser.accountTypeValue, businessUserId: 41, displayTitle: "伊犁那拉提景区-5A") + context.replaceScopes( + scenic: [BusinessScope(id: 128, name: "伊犁那拉提景区-5A", kind: .scenic)], + stores: [BusinessScope(id: 37, name: "随心旅拍", kind: .store, parentScenicId: 128)], + currentScenicId: 128, + currentStoreId: nil, + explicitSelection: true + ) + + XCTAssertEqual(context.accountType, V9ScenicUser.accountTypeValue) + XCTAssertNil(context.currentStore) + XCTAssertEqual(context.currentAccountDisplayTitle, "伊犁那拉提景区-5A") + } + /// 测试缓存 ID 不存在时会回退到首个可用作用域。 func testReplaceScopesFallsBackToFirstScopeWhenCachedIdIsMissing() { let context = AccountContext() diff --git a/suixinkanTests/AuthModelsTests.swift b/suixinkanTests/AuthModelsTests.swift index 3d97c06..42d9e92 100644 --- a/suixinkanTests/AuthModelsTests.swift +++ b/suixinkanTests/AuthModelsTests.swift @@ -81,6 +81,69 @@ final class AuthModelsTests: XCTestCase { XCTAssertEqual(response.primaryProfile?.displayName, "示例门店") } + /// 测试切换门店账号时会优先使用 isCurrent 标记校准景区和门店作用域。 + func testV9AuthResponsePreferredScopeSelectionUsesCurrentStoreAccount() throws { + let scenicJSON = """ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 1, + "scenic_name": "旧景区", + "is_current": false + } + """ + let storeJSON = """ + { + "account_type": "store_user", + "id": 201, + "user_id": 201, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "李四", + "phone": "13800138000", + "avatar": "", + "scenic_id": 2, + "scenic_name": "新景区", + "store_id": 20, + "store_name": "新门店", + "is_current": true + } + """ + let scenicUser = try JSONDecoder().decode(V9ScenicUser.self, from: Data(scenicJSON.utf8)) + let storeUser = try JSONDecoder().decode(V9StoreUser.self, from: Data(storeJSON.utf8)) + let response = V9AuthResponse(token: "token", scenicUsers: [scenicUser], storeUsers: [storeUser]) + + XCTAssertEqual(response.currentAccountIdentity?.accountType, V9StoreUser.accountTypeValue) + XCTAssertEqual(response.currentAccountIdentity?.businessUserId, 201) + XCTAssertEqual(response.preferredScopeSelection.scenicId, 2) + XCTAssertEqual(response.preferredScopeSelection.storeId, 20) + + let context = AccountContext() + context.replaceScopes( + scenic: [BusinessScope(id: 1, name: "旧景区", kind: .scenic)], + stores: [], + currentScenicId: 1, + currentStoreId: nil + ) + context.replaceScopes( + scenic: response.scenicScopes, + stores: response.storeScopes, + currentScenicId: response.preferredScopeSelection.scenicId, + currentStoreId: response.preferredScopeSelection.storeId + ) + + XCTAssertEqual(context.currentScenic?.id, 2) + XCTAssertEqual(context.currentStore?.id, 20) + } + /// 测试 LoginRequest 会填充后端要求的默认字段。 func testLoginRequestEncodesExpectedDefaults() throws { let request = LoginRequest(username: "18651857230", password: "secret123") diff --git a/suixinkanTests/HomeAllFunctionsBuilderTests.swift b/suixinkanTests/HomeAllFunctionsBuilderTests.swift index 2306bb8..57d3f2a 100644 --- a/suixinkanTests/HomeAllFunctionsBuilderTests.swift +++ b/suixinkanTests/HomeAllFunctionsBuilderTests.swift @@ -16,14 +16,14 @@ final class HomeAllFunctionsBuilderTests: XCTestCase { topLevelPermissions: [ permission(name: "基本信息", uri: "basic_info"), permission(name: "位置上报", uri: "location_report"), - permission(name: "举报摄影师", uri: "photographer_report"), + permission(name: "举报摄影师", uri: "report_photographer"), permission(name: "相册管理", uri: "album_list"), permission(name: "我的钱包", uri: "wallet") ], commonURIs: [] ) - XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "photographer_report", "wallet"]) + XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "report_photographer", "wallet"]) } /// 测试保持 API 顶层顺序,不使用 preferredOrder 重排。 diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index 6bd4262..327ca74 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -269,20 +269,20 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(uris, ["wallet", "message_center"]) } - /// 测试 photographer_report 属于 Android menuList 白名单,且可加入常用应用。 + /// 测试 report_photographer 属于 Android menuList 白名单,且可加入常用应用。 func testPhotographerReportIsWhitelistedAndCanBeAddedToCommon() { - XCTAssertTrue(HomeCommonMenuStore.androidHomeMenuURIs.contains("photographer_report")) + XCTAssertTrue(HomeCommonMenuStore.androidHomeMenuURIs.contains("report_photographer")) let store = HomeCommonMenuStore(defaults: makeIsolatedDefaults()) let uris = store.add( - "photographer_report", + "report_photographer", current: ["location_report"], - topLevelPermissionURIs: ["location_report", "photographer_report"], + topLevelPermissionURIs: ["location_report", "report_photographer"], roleCode: "photographer", - accountScope: "scenic_user_photographer_report" + accountScope: "scenic_user_report_photographer" ) - XCTAssertEqual(uris, ["location_report", "photographer_report"]) + XCTAssertEqual(uris, ["location_report", "report_photographer"]) } /// 测试非白名单顶层项占用前 4 名额时,默认常用不会用后续白名单项补位。 diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index d6208a5..0874b3b 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -47,7 +47,7 @@ final class HomeMenuRouterTests: XCTestCase { ("checkin_points", .destination(.punchPointList)), ("location_report", .destination(.locationReport)), ("location_report_history", .destination(.locationReportHistory)), - ("photographer_report", .destination(.photographerReport)), + ("report_photographer", .destination(.photographerReport)), ("pm", .destination(.projectManagement)), ("project_edit", .destination(.projectManagement)), ("pm_manager", .destination(.pmProjectManagement)), @@ -191,7 +191,7 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertTrue(uris.contains("sample_management")) XCTAssertTrue(uris.contains("checkin_points")) XCTAssertTrue(uris.contains("location_report")) - XCTAssertTrue(uris.contains("photographer_report")) + XCTAssertTrue(uris.contains("report_photographer")) XCTAssertTrue(uris.contains("pm")) XCTAssertTrue(uris.contains("pm_manager")) XCTAssertTrue(uris.contains("schedule_management")) @@ -209,7 +209,7 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) - XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_report", title: ""), .destination(.photographerReport)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "report_photographer", title: ""), .destination(.photographerReport)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))