修复账号切换后「我的」页当前账号展示错误,并统一举报摄影师首页 URI。

切换账号时以用户选中账号为准写入展示标题与业务作用域,避免沿用旧门店名称;同时将首页举报摄影师入口 URI 对齐为 report_photographer。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 17:21:04 +08:00
parent fcb86d43f5
commit 5ab9b1b324
23 changed files with 419 additions and 92 deletions

View File

@ -63,11 +63,22 @@ struct BusinessScope: Codable, Equatable, Identifiable {
final class AccountContext: ObservableObject { final class AccountContext: ObservableObject {
@Published private(set) var profile: AccountProfile? @Published private(set) var profile: AccountProfile?
@Published private(set) var accountType: String? @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 scenicScopes: [BusinessScope] = []
@Published private(set) var storeScopes: [BusinessScope] = [] @Published private(set) var storeScopes: [BusinessScope] = []
@Published var currentScenic: BusinessScope? @Published var currentScenic: BusinessScope?
@Published var currentStore: 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 /// Android
var accountCachePrefix: String? { var accountCachePrefix: String? {
let userId = profile?.userId.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" 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.profile = profile
self.accountType = accountType?.trimmingCharacters(in: .whitespacesAndNewlines) 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], scenic: [BusinessScope],
stores: [BusinessScope], stores: [BusinessScope],
currentScenicId: Int? = nil, currentScenicId: Int? = nil,
currentStoreId: Int? = nil currentStoreId: Int? = nil,
explicitSelection: Bool = false
) { ) {
scenicScopes = scenic scenicScopes = scenic
storeScopes = stores 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 currentScenic = currentScenicId.flatMap { id in
scenic.first { $0.id == id } scenic.first { $0.id == id }
} ?? currentScenic.flatMap { current in } ?? currentScenic.flatMap { current in
@ -104,7 +145,8 @@ final class AccountContext: ObservableObject {
currentStore = resolvedStore( currentStore = resolvedStore(
in: stores, in: stores,
currentScenic: currentScenic, currentScenic: currentScenic,
preferredStoreId: currentStoreId ?? currentStore?.id preferredStoreId: currentStoreId ?? currentStore?.id,
allowFallbackToFirst: true
) )
} }
@ -115,7 +157,8 @@ final class AccountContext: ObservableObject {
currentStore = resolvedStore( currentStore = resolvedStore(
in: storeScopes, in: storeScopes,
currentScenic: scenic, currentScenic: scenic,
preferredStoreId: currentStore?.id preferredStoreId: currentStore?.id,
allowFallbackToFirst: true
) )
} }
@ -134,6 +177,8 @@ final class AccountContext: ObservableObject {
func reset() { func reset() {
profile = nil profile = nil
accountType = nil accountType = nil
businessUserId = nil
currentAccountDisplayTitle = nil
scenicScopes = [] scenicScopes = []
storeScopes = [] storeScopes = []
currentScenic = nil currentScenic = nil
@ -144,7 +189,8 @@ final class AccountContext: ObservableObject {
private func resolvedStore( private func resolvedStore(
in stores: [BusinessScope], in stores: [BusinessScope],
currentScenic: BusinessScope?, currentScenic: BusinessScope?,
preferredStoreId: Int? preferredStoreId: Int?,
allowFallbackToFirst: Bool
) -> BusinessScope? { ) -> BusinessScope? {
let scenicId = currentScenic?.id let scenicId = currentScenic?.id
let storesForScenic = stores.filter { store in let storesForScenic = stores.filter { store in
@ -155,6 +201,13 @@ final class AccountContext: ObservableObject {
let store = storesForScenic.first(where: { $0.id == preferredStoreId }) { let store = storesForScenic.first(where: { $0.id == preferredStoreId }) {
return store return store
} }
guard allowFallbackToFirst else { return nil }
return storesForScenic.first return storesForScenic.first
} }
///
private func normalizedText(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
} }

View File

@ -26,7 +26,8 @@ struct AccountContextLoader {
cachedCurrentRoleCode: String? = nil, cachedCurrentRoleCode: String? = nil,
cachedLegacyRoleId: Int? = nil, cachedLegacyRoleId: Int? = nil,
cachedCurrentScenicId: Int? = nil, cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil cachedCurrentStoreId: Int? = nil,
explicitScopeSelection: Bool = false
) async throws { ) async throws {
try await restorePermissions( try await restorePermissions(
permissionContext: permissionContext, permissionContext: permissionContext,
@ -35,7 +36,8 @@ struct AccountContextLoader {
cachedCurrentRoleCode: cachedCurrentRoleCode, cachedCurrentRoleCode: cachedCurrentRoleCode,
cachedLegacyRoleId: cachedLegacyRoleId, cachedLegacyRoleId: cachedLegacyRoleId,
cachedCurrentScenicId: cachedCurrentScenicId, cachedCurrentScenicId: cachedCurrentScenicId,
cachedCurrentStoreId: cachedCurrentStoreId cachedCurrentStoreId: cachedCurrentStoreId,
explicitScopeSelection: explicitScopeSelection
) )
try await refreshSupplementalContext( try await refreshSupplementalContext(
accountContext: accountContext, accountContext: accountContext,
@ -43,7 +45,8 @@ struct AccountContextLoader {
profileAPI: profileAPI, profileAPI: profileAPI,
accountContextAPI: accountContextAPI, accountContextAPI: accountContextAPI,
cachedCurrentScenicId: cachedCurrentScenicId, cachedCurrentScenicId: cachedCurrentScenicId,
cachedCurrentStoreId: cachedCurrentStoreId cachedCurrentStoreId: cachedCurrentStoreId,
explicitScopeSelection: explicitScopeSelection
) )
} }
@ -55,7 +58,8 @@ struct AccountContextLoader {
cachedCurrentRoleCode: String? = nil, cachedCurrentRoleCode: String? = nil,
cachedLegacyRoleId: Int? = nil, cachedLegacyRoleId: Int? = nil,
cachedCurrentScenicId: Int? = nil, cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil cachedCurrentStoreId: Int? = nil,
explicitScopeSelection: Bool = false
) async throws { ) async throws {
let rolePermissions = try await accountContextAPI.rolePermissions() let rolePermissions = try await accountContextAPI.rolePermissions()
permissionContext.replaceRolePermissions( permissionContext.replaceRolePermissions(
@ -70,7 +74,8 @@ struct AccountContextLoader {
scenic: roleScenicScopes, scenic: roleScenicScopes,
stores: accountContext.storeScopes, stores: accountContext.storeScopes,
currentScenicId: cachedCurrentScenicId, currentScenicId: cachedCurrentScenicId,
currentStoreId: cachedCurrentStoreId currentStoreId: cachedCurrentStoreId,
explicitSelection: explicitScopeSelection
) )
} }
@ -81,7 +86,8 @@ struct AccountContextLoader {
profileAPI: UserProfileServing, profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing, accountContextAPI: AccountContextServing,
cachedCurrentScenicId: Int? = nil, cachedCurrentScenicId: Int? = nil,
cachedCurrentStoreId: Int? = nil cachedCurrentStoreId: Int? = nil,
explicitScopeSelection: Bool = false
) async throws { ) async throws {
let userInfo = try await profileAPI.userInfo() let userInfo = try await profileAPI.userInfo()
let scenicResponse = await loadScenicList( let scenicResponse = await loadScenicList(
@ -94,19 +100,57 @@ struct AccountContextLoader {
permissionContext: permissionContext, permissionContext: permissionContext,
scenicResponse: scenicResponse 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) BusinessScope(id: store.id, name: store.name, kind: .store, parentScenicId: store.scenicId)
} ?? [] } ?? []
// /退
let storeScopes = fetchedStoreScopes.isEmpty ? accountContext.storeScopes : fetchedStoreScopes
accountContext.replaceProfile(profile) accountContext.replaceProfile(profile)
accountContext.replaceScopes( accountContext.replaceScopes(
scenic: scenicScopes, scenic: scenicScopes,
stores: storeScopes, stores: storeScopes,
currentScenicId: cachedCurrentScenicId ?? accountContext.currentScenic?.id, currentScenicId: resolvedScenicId(
currentStoreId: cachedCurrentStoreId ?? accountContext.currentStore?.id 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( private func loadScenicList(
accountContextAPI: AccountContextServing, accountContextAPI: AccountContextServing,

View File

@ -52,7 +52,8 @@ final class AuthSessionCoordinator {
accountContext: AccountContext, accountContext: AccountContext,
permissionContext: PermissionContext, permissionContext: PermissionContext,
profileAPI: UserProfileServing, profileAPI: UserProfileServing,
accountContextAPI: AccountContextServing accountContextAPI: AccountContextServing,
selectedAccount: AccountSwitchAccount? = nil
) async throws { ) async throws {
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
try tokenStore.save(token) try tokenStore.save(token)
@ -62,9 +63,22 @@ final class AuthSessionCoordinator {
let profile = response.primaryProfile let profile = response.primaryProfile
let scenicScopes = response.scenicScopes let scenicScopes = response.scenicScopes
let storeScopes = response.storeScopes let storeScopes = response.storeScopes
let identity = response.currentAccountIdentity let identity = resolvedIdentity(from: response, selectedAccount: selectedAccount)
accountContext.applyLogin(profile: profile, accountType: identity?.accountType) let scopeSelection = selectedAccount?.preferredScopeSelection ?? response.preferredScopeSelection
accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes) 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) appSession.beginRestoring(token: token)
do { do {
@ -74,8 +88,9 @@ final class AuthSessionCoordinator {
profileAPI: profileAPI, profileAPI: profileAPI,
accountContextAPI: accountContextAPI, accountContextAPI: accountContextAPI,
cachedCurrentRoleCode: response.currentAccountRoleCode, cachedCurrentRoleCode: response.currentAccountRoleCode,
cachedCurrentScenicId: accountContext.currentScenic?.id, cachedCurrentScenicId: scopeSelection.scenicId,
cachedCurrentStoreId: accountContext.currentStore?.id cachedCurrentStoreId: scopeSelection.storeId,
explicitScopeSelection: true
) )
} catch { } catch {
if APIError.isAuthenticationExpired(error) { if APIError.isAuthenticationExpired(error) {
@ -153,6 +168,7 @@ final class AuthSessionCoordinator {
profile: profile, profile: profile,
accountType: accountType, accountType: accountType,
businessUserId: businessUserId, businessUserId: businessUserId,
currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle,
currentRoleCode: permissionContext?.currentAppRole?.rawValue ?? currentRoleCode, currentRoleCode: permissionContext?.currentAppRole?.rawValue ?? currentRoleCode,
scenicScopes: accountContext.scenicScopes, scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes, 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 { private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile {
AccountProfile( AccountProfile(
@ -185,27 +212,3 @@ struct LoginPreferences: Equatable {
let privacyAgreementAccepted: Bool 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
}
}

View File

@ -89,12 +89,18 @@ final class SessionBootstrapper {
/// ///
private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? { private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? {
guard let snapshot = snapshotStore.load() else { return nil } 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( accountContext.replaceScopes(
scenic: snapshot.scenicScopes, scenic: snapshot.scenicScopes,
stores: snapshot.storeScopes, stores: snapshot.storeScopes,
currentScenicId: snapshot.currentScenicId, currentScenicId: snapshot.currentScenicId,
currentStoreId: snapshot.currentStoreId currentStoreId: snapshot.currentStoreId,
explicitSelection: true
) )
return snapshot return snapshot
} }
@ -198,6 +204,7 @@ final class SessionBootstrapper {
profile: accountContext.profile, profile: accountContext.profile,
accountType: accountContext.accountType ?? existing?.accountType, accountType: accountContext.accountType ?? existing?.accountType,
businessUserId: existing?.businessUserId, businessUserId: existing?.businessUserId,
currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle ?? existing?.currentAccountDisplayTitle,
currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode, currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode,
scenicScopes: accountContext.scenicScopes, scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes, storeScopes: accountContext.storeScopes,

View File

@ -13,6 +13,7 @@ struct AccountSnapshot: Codable, Equatable {
var profile: AccountProfile? var profile: AccountProfile?
var accountType: String? var accountType: String?
var businessUserId: Int? var businessUserId: Int?
var currentAccountDisplayTitle: String?
var currentRoleCode: String? var currentRoleCode: String?
/// ///
var currentRoleId: Int? var currentRoleId: Int?
@ -26,6 +27,7 @@ struct AccountSnapshot: Codable, Equatable {
profile: AccountProfile? = nil, profile: AccountProfile? = nil,
accountType: String? = nil, accountType: String? = nil,
businessUserId: Int? = nil, businessUserId: Int? = nil,
currentAccountDisplayTitle: String? = nil,
currentRoleCode: String? = nil, currentRoleCode: String? = nil,
currentRoleId: Int? = nil, currentRoleId: Int? = nil,
scenicScopes: [BusinessScope] = [], scenicScopes: [BusinessScope] = [],
@ -36,6 +38,7 @@ struct AccountSnapshot: Codable, Equatable {
self.profile = profile self.profile = profile
self.accountType = accountType self.accountType = accountType
self.businessUserId = businessUserId self.businessUserId = businessUserId
self.currentAccountDisplayTitle = currentAccountDisplayTitle
self.currentRoleCode = currentRoleCode self.currentRoleCode = currentRoleCode
self.currentRoleId = currentRoleId self.currentRoleId = currentRoleId
self.scenicScopes = scenicScopes self.scenicScopes = scenicScopes
@ -117,6 +120,7 @@ final class AccountSnapshotStore {
profile: accountContext.profile ?? existing?.profile, profile: accountContext.profile ?? existing?.profile,
accountType: accountContext.accountType ?? existing?.accountType, accountType: accountContext.accountType ?? existing?.accountType,
businessUserId: existing?.businessUserId, businessUserId: existing?.businessUserId,
currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle ?? existing?.currentAccountDisplayTitle,
currentRoleCode: currentRoleCode ?? existing?.currentRoleCode, currentRoleCode: currentRoleCode ?? existing?.currentRoleCode,
scenicScopes: accountContext.scenicScopes, scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes, storeScopes: accountContext.storeScopes,

View File

@ -76,6 +76,14 @@ struct AccountSwitchAccount: Identifiable, Hashable {
} }
return SetUserRequest(ssUserId: businessUserId) return SetUserRequest(ssUserId: businessUserId)
} }
/// ID
var preferredScopeSelection: (scenicId: Int?, storeId: Int?) {
if isStoreUser {
return (scenicId, storeId)
}
return (scenicId, nil)
}
} }
/// token /// token
@ -100,9 +108,9 @@ struct V9AuthResponse: Decodable, Equatable {
scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() } scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() }
} }
/// /// 使 isCurrent
var primaryProfile: AccountProfile? { var primaryProfile: AccountProfile? {
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { if let storeUser = resolvedCurrentStoreUser {
return AccountProfile( return AccountProfile(
userId: String(storeUser.userId), userId: String(storeUser.userId),
displayName: storeUser.displayName, 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( return AccountProfile(
userId: String(scenicUser.userId), userId: String(scenicUser.userId),
displayName: scenicUser.displayName, displayName: scenicUser.displayName,
@ -123,6 +131,60 @@ struct V9AuthResponse: Decodable, Equatable {
return nil 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 scenicScopes: [BusinessScope] {
var seen = Set<Int>() var seen = Set<Int>()
@ -159,10 +221,10 @@ struct V9AuthResponse: Decodable, Equatable {
/// app_role_code /// app_role_code
var currentAccountRoleCode: String? { var currentAccountRoleCode: String? {
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { if let storeUser = resolvedCurrentStoreUser {
return nonEmpty(storeUser.resolvedAppRoleCode) return nonEmpty(storeUser.resolvedAppRoleCode)
} }
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first { if let scenicUser = resolvedCurrentScenicUser {
return nonEmpty(scenicUser.resolvedAppRoleCode) return nonEmpty(scenicUser.resolvedAppRoleCode)
} }
return nil return nil

View File

@ -179,7 +179,7 @@ struct LoginView: View {
do { do {
try await globalLoading.withLoading(message: "账号切换中...") { try await globalLoading.withLoading(message: "账号切换中...") {
let response = try await viewModel.selectAccount(account, authAPI: authAPI) let response = try await viewModel.selectAccount(account, authAPI: authAPI)
await completeLogin(with: response) await completeLogin(with: response, selectedAccount: account)
} }
} catch is CancellationError { } catch is CancellationError {
// Keep cancellation silent; the current task was abandoned by SwiftUI. // 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 { do {
try await authSessionCoordinator.completeLogin( try await authSessionCoordinator.completeLogin(
with: response, with: response,
@ -200,7 +200,8 @@ struct LoginView: View {
accountContext: accountContext, accountContext: accountContext,
permissionContext: permissionContext, permissionContext: permissionContext,
profileAPI: profileAPI, profileAPI: profileAPI,
accountContextAPI: accountContextAPI accountContextAPI: accountContextAPI,
selectedAccount: selectedAccount
) )
} catch { } catch {
toastCenter.show(error.localizedDescription) toastCenter.show(error.localizedDescription)

View File

@ -80,7 +80,7 @@ allFunctions = 顶层权限
.映射为 HomeMenuItem .映射为 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 **不会**出现在「全部功能」页,即使接口返回了顶层权限: 以下典型 URI **不会**出现在「全部功能」页,即使接口返回了顶层权限:

View File

@ -62,4 +62,4 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
`photographer_report` 已由 `Features/WildPhotographerReport` 接管。 `report_photographer` 已由 `Features/WildPhotographerReport` 接管。

View File

@ -46,7 +46,7 @@ enum HomeMenuRouter {
"pm_manager": "店铺项目管理", "pm_manager": "店铺项目管理",
"project_edit": "项目管理", "project_edit": "项目管理",
"location_report": "位置上报", "location_report": "位置上报",
"photographer_report": "举报摄影师", "report_photographer": "举报摄影师",
"registration_invitation": "注册邀请", "registration_invitation": "注册邀请",
"store": "店铺管理", "store": "店铺管理",
"fly": "飞行管理", "fly": "飞行管理",
@ -132,7 +132,7 @@ enum HomeMenuRouter {
return .destination(.locationReport) return .destination(.locationReport)
case "location_report_history": case "location_report_history":
return .destination(.locationReportHistory) return .destination(.locationReportHistory)
case "photographer_report": case "report_photographer":
return .destination(.photographerReport) return .destination(.photographerReport)
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
return .destination(.depositOrders) return .destination(.depositOrders)
@ -219,7 +219,7 @@ enum HomeMenuRouter {
"pm_manager", "pm_manager",
"project_edit", "project_edit",
"location_report", "location_report",
"photographer_report", "report_photographer",
"registration_invitation", "registration_invitation",
"photographer_invite", "photographer_invite",
"invite_record", "invite_record",
@ -298,7 +298,7 @@ enum HomeMenuRouter {
return "注册邀请" return "注册邀请"
case "location_report": case "location_report":
return "位置上报" return "位置上报"
case "photographer_report": case "report_photographer":
return "举报摄影师" return "举报摄影师"
case "pm", "project_edit": case "pm", "project_edit":
return "项目管理" return "项目管理"

View File

@ -30,7 +30,7 @@ struct HomeCommonMenuStore {
"travel_album", "travel_album",
"pm", "pm",
"location_report", "location_report",
"photographer_report", "report_photographer",
"registration_invitation", "registration_invitation",
"store", "store",
"fly", "fly",

View File

@ -54,7 +54,7 @@ enum HomeIconCatalog {
"circle.grid.2x2.fill" "circle.grid.2x2.fill"
case "location_report", "location_report_history": case "location_report", "location_report_history":
"mappin.circle.fill" "mappin.circle.fill"
case "photographer_report": case "report_photographer":
"shield.fill" "shield.fill"
case "registration_invitation", "photographer_invite": case "registration_invitation", "photographer_invite":
"envelope.open.fill" "envelope.open.fill"

View File

@ -125,3 +125,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
- 无实名认证信息时:点击去实名认证。 - 无实名认证信息时:点击去实名认证。
- 账号切换失败只展示 Toast不清空登录态。 - 账号切换失败只展示 Toast不清空登录态。
- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。 - 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。
- 「当前账号」展示规则:
- 门店账号(`store_user`)展示门店名称,不回退到景区名称。
- 景区账号(`scenic_user`)展示景区名称。
- 展示标题在登录/切换成功时写入 `AccountContext.currentAccountDisplayTitle`,切换账号时必须同步更新,不能沿用上一次账号名称。

View File

@ -212,7 +212,8 @@ struct AccountSwitchView: View {
accountContext: accountContext, accountContext: accountContext,
permissionContext: permissionContext, permissionContext: permissionContext,
profileAPI: profileAPI, profileAPI: profileAPI,
accountContextAPI: accountContextAPI accountContextAPI: accountContextAPI,
selectedAccount: account
) )
} }
appRouter.reset() appRouter.reset()
@ -226,14 +227,7 @@ struct AccountSwitchView: View {
} }
private var currentAccountId: String? { private var currentAccountId: String? {
guard let current = accountContext.profile else { return nil } accountContext.currentAccountIdentifier
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
} }
/// ///

View File

@ -494,20 +494,51 @@ struct ProfileView: View {
} }
private var currentAccountDisplayName: String { private var currentAccountDisplayName: String {
nonEmpty(accountContext.currentAccountDisplayTitle)
?? (isStoreAccountContext ? resolvedStoreAccountName : resolvedScenicAccountName)
}
/// 退
private var resolvedStoreAccountName: String {
nonEmpty(accountContext.currentStore?.name) nonEmpty(accountContext.currentStore?.name)
?? nonEmpty(accountContext.currentScenic?.name) ?? nonEmpty(matchedStoreScope?.name)
?? accountContext.profile?.displayName ?? nonEmpty(accountContext.profile?.displayName)
?? viewModel.displayNickname ?? 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 { private var currentAccountTypeText: String {
if accountContext.currentStore != nil { switch accountContext.accountType {
case V9StoreUser.accountTypeValue:
return "门店账号" return "门店账号"
} case V9ScenicUser.accountTypeValue:
if accountContext.currentScenic != nil {
return "景区账号" return "景区账号"
default:
return nonEmpty(viewModel.userInfo?.roleName) ?? "账号"
} }
return nonEmpty(viewModel.userInfo?.roleName) ?? "账号"
} }
private var nicknameBinding: Binding<String> { private var nicknameBinding: Binding<String> {

View File

@ -576,6 +576,7 @@ struct ProfileSpaceView: View {
profile: profile, profile: profile,
accountType: accountContext.accountType ?? existing?.accountType, accountType: accountContext.accountType ?? existing?.accountType,
businessUserId: existing?.businessUserId, businessUserId: existing?.businessUserId,
currentAccountDisplayTitle: accountContext.currentAccountDisplayTitle ?? existing?.currentAccountDisplayTitle,
currentRoleCode: existing?.currentRoleCode, currentRoleCode: existing?.currentRoleCode,
scenicScopes: accountContext.scenicScopes, scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes, storeScopes: accountContext.storeScopes,

View File

@ -2,7 +2,7 @@
## 模块职责 ## 模块职责
WildPhotographerReport 模块负责首页 `photographer_report` 入口,为摄影师和景区运营提供「举报摄影师」能力。 WildPhotographerReport 模块负责首页 `report_photographer` 入口,为摄影师和景区运营提供「举报摄影师」能力。
本模块与 `LocationReport`(位置上报)无关:前者是用户举报疑似打野/未戴工牌摄影师,后者是摄影师 GPS 打卡。 本模块与 `LocationReport`(位置上报)无关:前者是用户举报疑似打野/未戴工牌摄影师,后者是摄影师 GPS 打卡。
@ -49,7 +49,7 @@ flowchart TD
## 路由 ## 路由
- 权限 URI`photographer_report` - 权限 URI`report_photographer`
- 首页路由:`HomeRoute.photographerReport` - 首页路由:`HomeRoute.photographerReport`
- 入口不固定置顶,依赖权限下发或用户手动添加到常用应用(`HomeCommonMenuStore.androidHomeMenuURIs` 白名单已包含该 URI - 入口不固定置顶,依赖权限下发或用户手动添加到常用应用(`HomeCommonMenuStore.androidHomeMenuURIs` 白名单已包含该 URI
@ -57,10 +57,10 @@ flowchart TD
| 模块 | URI | 用途 | 后端 | | 模块 | URI | 用途 | 后端 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| WildPhotographerReport | `photographer_report` | 举报摄影师 | Mock | | WildPhotographerReport | `report_photographer` | 举报摄影师 | Mock |
| LocationReport | `location_report` | 位置上报 | 真实 API | | LocationReport | `location_report` | 位置上报 | 真实 API |
## 测试要求 ## 测试要求
- `WildPhotographerReportTests`Home/Submit/List ViewModel 校验与提交成功路径。 - `WildPhotographerReportTests`Home/Submit/List ViewModel 校验与提交成功路径。
- `HomeMenuRouterTests``photographer_report` 路由映射。 - `HomeMenuRouterTests``report_photographer` 路由映射。

View File

@ -147,6 +147,40 @@ final class AccountContextLoaderTests: XCTestCase {
XCTAssertTrue(accountContext.storeScopes.isEmpty) XCTAssertTrue(accountContext.storeScopes.isEmpty)
XCTAssertNil(accountContext.currentStore) 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 @MainActor

View File

@ -46,6 +46,32 @@ final class AccountContextTests: XCTestCase {
XCTAssertEqual(context.currentStore?.id, 20) 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 退 /// ID 退
func testReplaceScopesFallsBackToFirstScopeWhenCachedIdIsMissing() { func testReplaceScopesFallsBackToFirstScopeWhenCachedIdIsMissing() {
let context = AccountContext() let context = AccountContext()

View File

@ -81,6 +81,69 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(response.primaryProfile?.displayName, "示例门店") 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 /// LoginRequest
func testLoginRequestEncodesExpectedDefaults() throws { func testLoginRequestEncodesExpectedDefaults() throws {
let request = LoginRequest(username: "18651857230", password: "secret123") let request = LoginRequest(username: "18651857230", password: "secret123")

View File

@ -16,14 +16,14 @@ final class HomeAllFunctionsBuilderTests: XCTestCase {
topLevelPermissions: [ topLevelPermissions: [
permission(name: "基本信息", uri: "basic_info"), permission(name: "基本信息", uri: "basic_info"),
permission(name: "位置上报", uri: "location_report"), permission(name: "位置上报", uri: "location_report"),
permission(name: "举报摄影师", uri: "photographer_report"), permission(name: "举报摄影师", uri: "report_photographer"),
permission(name: "相册管理", uri: "album_list"), permission(name: "相册管理", uri: "album_list"),
permission(name: "我的钱包", uri: "wallet") permission(name: "我的钱包", uri: "wallet")
], ],
commonURIs: [] commonURIs: []
) )
XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "photographer_report", "wallet"]) XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "report_photographer", "wallet"])
} }
/// API 使 preferredOrder /// API 使 preferredOrder

View File

@ -269,20 +269,20 @@ final class HomeCommonMenuStoreTests: XCTestCase {
XCTAssertEqual(uris, ["wallet", "message_center"]) XCTAssertEqual(uris, ["wallet", "message_center"])
} }
/// photographer_report Android menuList /// report_photographer Android menuList
func testPhotographerReportIsWhitelistedAndCanBeAddedToCommon() { func testPhotographerReportIsWhitelistedAndCanBeAddedToCommon() {
XCTAssertTrue(HomeCommonMenuStore.androidHomeMenuURIs.contains("photographer_report")) XCTAssertTrue(HomeCommonMenuStore.androidHomeMenuURIs.contains("report_photographer"))
let store = HomeCommonMenuStore(defaults: makeIsolatedDefaults()) let store = HomeCommonMenuStore(defaults: makeIsolatedDefaults())
let uris = store.add( let uris = store.add(
"photographer_report", "report_photographer",
current: ["location_report"], current: ["location_report"],
topLevelPermissionURIs: ["location_report", "photographer_report"], topLevelPermissionURIs: ["location_report", "report_photographer"],
roleCode: "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 /// 4

View File

@ -47,7 +47,7 @@ final class HomeMenuRouterTests: XCTestCase {
("checkin_points", .destination(.punchPointList)), ("checkin_points", .destination(.punchPointList)),
("location_report", .destination(.locationReport)), ("location_report", .destination(.locationReport)),
("location_report_history", .destination(.locationReportHistory)), ("location_report_history", .destination(.locationReportHistory)),
("photographer_report", .destination(.photographerReport)), ("report_photographer", .destination(.photographerReport)),
("pm", .destination(.projectManagement)), ("pm", .destination(.projectManagement)),
("project_edit", .destination(.projectManagement)), ("project_edit", .destination(.projectManagement)),
("pm_manager", .destination(.pmProjectManagement)), ("pm_manager", .destination(.pmProjectManagement)),
@ -191,7 +191,7 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertTrue(uris.contains("sample_management")) XCTAssertTrue(uris.contains("sample_management"))
XCTAssertTrue(uris.contains("checkin_points")) XCTAssertTrue(uris.contains("checkin_points"))
XCTAssertTrue(uris.contains("location_report")) XCTAssertTrue(uris.contains("location_report"))
XCTAssertTrue(uris.contains("photographer_report")) XCTAssertTrue(uris.contains("report_photographer"))
XCTAssertTrue(uris.contains("pm")) XCTAssertTrue(uris.contains("pm"))
XCTAssertTrue(uris.contains("pm_manager")) XCTAssertTrue(uris.contains("pm_manager"))
XCTAssertTrue(uris.contains("schedule_management")) 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: "sample_upload", title: ""), .destination(.sampleUpload))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) 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", title: ""), .destination(.projectManagement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))