feat(tenant): show rich workspace-switch confirmation that survives reload

Replace the lightweight "switch success" message with a richer
NotifyPlugin notification that reports the new workspace name and the
user's role in it. Because the post-switch flow does a hard reload,
the notification is stashed in sessionStorage before navigation and
consumed by App.vue on the next mount, so the toast actually appears
on the destination page with its full 6s duration instead of being
torn down a frame after it is shown.

For the cross-tenant superuser path (TenantSelector), the role line is
omitted when the user has no membership row in the target tenant,
avoiding a misleading empty/raw role value. Role labels are formatted
through the shared useRoleLabel composable so any future role text
change still lives in one place.

i18n keys updated across zh-CN, en-US, ko-KR, ru-RU.
This commit is contained in:
wizardchen
2026-05-18 19:34:06 +08:00
committed by lyingbug
parent c19d3543c8
commit 0655b545be
8 changed files with 83 additions and 10 deletions

View File

@@ -2,11 +2,12 @@
import { computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { MessagePlugin } from 'tdesign-vue-next'
import { MessagePlugin, NotifyPlugin } from 'tdesign-vue-next'
import ManualKnowledgeEditor from '@/components/manual-knowledge-editor.vue'
import { useAuthStore } from '@/stores/auth'
import { useSettingsStore } from '@/stores/settings'
import { getCurrentUser } from '@/api/auth'
import { consumePendingTenantSwitchToast } from '@/utils/tenantSwitch'
// TDesign locale configs
import enUSConfig from 'tdesign-vue-next/esm/locale/en_US'
@@ -14,7 +15,7 @@ import zhCNConfig from 'tdesign-vue-next/esm/locale/zh_CN'
import koKRConfig from 'tdesign-vue-next/esm/locale/ko_KR'
import ruRUConfig from 'tdesign-vue-next/esm/locale/ru_RU'
const { locale } = useI18n()
const { locale, t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
const settingsStore = useSettingsStore()
@@ -183,8 +184,24 @@ watch(
{ immediate: true },
)
// 切换租户后会 hard reload切换前 stash 的 toast 这里 consume 并弹出,
// 这样 toast 显示在新页面上duration 才真正生效。
const showPendingTenantSwitchToast = () => {
const pending = consumePendingTenantSwitchToast()
if (!pending) return
NotifyPlugin.success({
title: t('tenant.switchSuccessTitle'),
content: pending.role
? t('tenant.switchSuccessContentWithRole', { name: pending.name, role: pending.role })
: t('tenant.switchSuccessContent', { name: pending.name }),
duration: 6000,
closeBtn: true,
})
}
onMounted(() => {
handleGlobalOIDCCallback()
showPendingTenantSwitchToast()
// Auto check for updates on startup
setTimeout(() => {

View File

@@ -76,11 +76,13 @@ import { useAuthStore } from '@/stores/auth'
import { searchTenants, type TenantInfo } from '@/api/tenant'
import { useI18n } from 'vue-i18n'
import { MessagePlugin } from 'tdesign-vue-next'
import { navigateAfterTenantSwitch } from '@/utils/tenantSwitch'
import { navigateAfterTenantSwitch, stashTenantSwitchToast } from '@/utils/tenantSwitch'
import CreateTenantDialog from '@/components/CreateTenantDialog.vue'
import { useRoleLabel } from '@/composables/useRoleLabel'
const { t } = useI18n()
const authStore = useAuthStore()
const { formatRole } = useRoleLabel()
const showDropdown = ref(false)
const searchQuery = ref('')
@@ -164,7 +166,16 @@ const selectTenant = (tenantId: number) => {
authStore.setSelectedTenant(tenantId, selectedTenant?.name || null)
}
closeDropdown()
MessagePlugin.success(t('tenant.switchSuccess'))
const displayName = selectedTenant?.name
|| (tenantId === defaultTenantId.value ? authStore.tenant?.name : null)
|| `#${tenantId}`
// Cross-tenant superusers may not have a membership row in the target
// tenant; in that case skip the role line rather than show a misleading
// empty/raw value.
const membership = (authStore.memberships ?? []).find((m) => Number(m.tenant_id) === tenantId)
const roleLabel = membership ? formatRole(membership.role) : ''
// Toast 在 reload 后由 App.vue 弹出(直接在这里弹会被 hard reload 干掉)。
stashTenantSwitchToast({ name: displayName, role: roleLabel || undefined })
// 切换租户后跳转到新租户下安全的入口(详见 tenantSwitch.ts 注释)。
setTimeout(() => {
navigateAfterTenantSwitch()

View File

@@ -236,7 +236,7 @@ import { useI18n } from 'vue-i18n'
import IMChannelsOverviewPanel from '@/components/IMChannelsOverviewPanel.vue'
import CreateTenantDialog from '@/components/CreateTenantDialog.vue'
import { listAllIMChannels, type IMChannelOverview } from '@/api/agent'
import { navigateAfterTenantSwitch } from '@/utils/tenantSwitch'
import { navigateAfterTenantSwitch, stashTenantSwitchToast } from '@/utils/tenantSwitch'
import type { TenantInfo } from '@/api/tenant'
import { useRoleLabel, useHomeTenant } from '@/composables/useRoleLabel'
@@ -446,7 +446,11 @@ const switchToTenant = (m: Membership) => {
authStore.setSelectedTenant(m.tenant_id, tenantDisplayName(m))
}
closeAll()
MessagePlugin.success(t('tenant.switchSuccess'))
// Toast 在 reload 后由 App.vue 弹出(直接在这里弹会被 hard reload 干掉)。
stashTenantSwitchToast({
name: tenantDisplayName(m),
role: formatRole(m.role) || undefined,
})
// Hard reload so every cached store / open SSE stream / in-flight
// request gets re-keyed under the new tenant. If the current path
// embeds a tenant-scoped resource id, reload would white-screen the

View File

@@ -2389,7 +2389,9 @@ export default {
searchPlaceholder: 'Search by name or enter tenant ID...',
searchHint: 'Search by name or enter tenant ID directly',
noMatch: 'No matching tenants found',
switchSuccess: 'Tenant switched successfully',
switchSuccessTitle: 'Workspace switched',
switchSuccessContent: 'You are now in "{name}"',
switchSuccessContentWithRole: 'You are now in "{name}" as {role}',
loadTenantsFailed: 'Failed to load tenant list',
loading: 'Loading...',
loadMore: 'Load more',

View File

@@ -1637,7 +1637,9 @@ export default {
searchPlaceholder: "테넌트 이름 검색 또는 테넌트 ID 입력...",
searchHint: "이름으로 검색하거나 테넌트 ID를 직접 입력할 수 있습니다",
noMatch: "일치하는 테넌트를 찾을 수 없습니다",
switchSuccess: "테넌트 전환 성공",
switchSuccessTitle: "공간이 전환되었습니다",
switchSuccessContent: "「{name}」에 입장했습니다",
switchSuccessContentWithRole: "「{name}」에 입장했습니다 · 역할: {role}",
loadTenantsFailed: "테넌트 목록 로드 실패",
loading: "로딩 중...",
loadMore: "더 보기",

View File

@@ -1435,7 +1435,9 @@ export default {
searchPlaceholder: 'Поиск по имени или введите ID арендатора...',
searchHint: 'Поиск по имени или введите ID арендатора напрямую',
noMatch: 'Не найдено подходящих арендаторов',
switchSuccess: 'Арендатор успешно переключен',
switchSuccessTitle: 'Пространство переключено',
switchSuccessContent: 'Вы вошли в «{name}»',
switchSuccessContentWithRole: 'Вы вошли в «{name}» · роль: {role}',
loadTenantsFailed: 'Не удалось загрузить список арендаторов',
loading: 'Загрузка...',
loadMore: 'Загрузить еще',

View File

@@ -1617,7 +1617,9 @@ export default {
searchPlaceholder: "搜索空间名称或输入空间 ID...",
searchHint: "支持按名称搜索或直接输入空间 ID",
noMatch: "未找到匹配的空间",
switchSuccess: "空间切换成功",
switchSuccessTitle: "已切换空间",
switchSuccessContent: "你已进入「{name}」",
switchSuccessContentWithRole: "你已进入「{name}」,身份:{role}",
loadTenantsFailed: "加载空间列表失败",
loading: "加载中...",
loadMore: "加载更多",

View File

@@ -22,3 +22,36 @@ export function tenantSwitchTargetPath(_currentPath: string): string {
export function navigateAfterTenantSwitch(): void {
window.location.href = tenantSwitchTargetPath(window.location.pathname)
}
// 切换成功后的 toast 跨 hard reload 传递:调用方在 reload 前把信息塞进
// sessionStorageApp.vue 启动时 consume 一次再弹出。直接在 reload 前调
// NotifyPlugin 会被刷掉,根本来不及看清。
const PENDING_TOAST_KEY = 'weknora_pending_tenant_switch_toast'
export interface PendingTenantSwitchToast {
name: string
role?: string
}
export function stashTenantSwitchToast(payload: PendingTenantSwitchToast): void {
try {
sessionStorage.setItem(PENDING_TOAST_KEY, JSON.stringify(payload))
} catch {
// sessionStorage 写失败隐私模式等就静默放弃toast 是锦上添花
}
}
export function consumePendingTenantSwitchToast(): PendingTenantSwitchToast | null {
try {
const raw = sessionStorage.getItem(PENDING_TOAST_KEY)
if (!raw) return null
sessionStorage.removeItem(PENDING_TOAST_KEY)
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.name === 'string') {
return { name: parsed.name, role: typeof parsed.role === 'string' ? parsed.role : undefined }
}
return null
} catch {
return null
}
}