Files
Enterprise-Knowledge-Base/frontend/src/App.vue
T
2026-07-07 12:00:13 +08:00

292 lines
9.5 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { MessagePlugin, NotifyPlugin } from 'tdesign-vue-next'
import ManualKnowledgeEditor from '@/components/manual-knowledge-editor.vue'
import UploadConfirmHost from '@/components/UploadConfirmHost.vue'
import { useAuthStore } from '@/stores/auth'
import { useSettingsStore } from '@/stores/settings'
import { getCurrentUser, userInfoFromApi } from '@/api/auth'
import { consumePendingTenantSwitchToast } from '@/utils/tenantSwitch'
import { useRoleLabel } from '@/composables/useRoleLabel'
import { notifyLoginSuccess } from '@/utils/loginNotify'
import { renderWorkspaceNotifyContent } from '@/utils/workspaceNotifyContent'
// TDesign locale configs
import enUSConfig from 'tdesign-vue-next/esm/locale/en_US'
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, t, tm } = useI18n()
const { formatRole, roleIcon } = useRoleLabel()
const router = useRouter()
const authStore = useAuthStore()
const settingsStore = useSettingsStore()
const tdLocaleMap: Record<string, object> = {
'en-US': enUSConfig,
'zh-CN': zhCNConfig,
'ko-KR': koKRConfig,
'ru-RU': ruRUConfig,
}
const tdGlobalConfig = computed(() => tdLocaleMap[locale.value] || enUSConfig)
const decodeOIDCResult = (encoded: string) => {
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
const binary = window.atob(padded)
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes))
}
const clearOIDCCallbackState = (path = '/') => {
window.history.replaceState({}, document.title, path)
}
const syncOIDCUserContext = async () => {
const currentUserResponse = await getCurrentUser()
if (!currentUserResponse.success || !currentUserResponse.data?.user) {
throw new Error(currentUserResponse.message || 'Failed to get user information')
}
const { user, tenant, memberships } = currentUserResponse.data
authStore.setUser(userInfoFromApi(user, tenant?.id))
if (tenant) {
authStore.setTenant({
id: String(tenant.id) || '',
name: tenant.name || '',
owner_id: tenant.owner_id || user.id || '',
description: tenant.description,
status: tenant.status,
business: tenant.business,
storage_quota: tenant.storage_quota,
storage_used: tenant.storage_used,
created_at: tenant.created_at || new Date().toISOString(),
updated_at: tenant.updated_at || new Date().toISOString()
})
}
// Refresh memberships so currentTenantRole reflects any role change
// since the last login (e.g. an Owner demoted us to Viewer in a
// peer tenant). Without this, memberships stay frozen at the
// login-time snapshot and the UI silently lies about our authority.
if (Array.isArray(memberships)) {
authStore.setMemberships(memberships)
}
// Same active-vs-home reconciliation as Login.vue: if the OIDC login
// landed us in a non-home tenant (because the backend honoured a
// remembered last-active-tenant preference) make sure X-Tenant-ID
// override is set; otherwise drop any stale override.
const activeIdNum = tenant?.id != null ? Number(tenant.id) : NaN
const homeIdNum = user.tenant_id != null ? Number(user.tenant_id) : NaN
if (Number.isFinite(activeIdNum) && Number.isFinite(homeIdNum) && activeIdNum !== homeIdNum) {
authStore.setSelectedTenant(activeIdNum, tenant?.name || null)
} else {
authStore.setSelectedTenant(null, null)
}
}
const persistOIDCLoginResponse = async (response: any) => {
if (!response.token) {
throw new Error(response.message || 'OIDC login failed')
}
authStore.setToken(response.token)
if (response.refresh_token) {
authStore.setRefreshToken(response.refresh_token)
}
await syncOIDCUserContext()
await nextTick()
router.replace('/platform/knowledge-bases')
}
const handleGlobalOIDCCallback = async () => {
const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''
if (!hash) return
const params = new URLSearchParams(hash)
const oidcError = params.get('oidc_error')
const oidcErrorDescription = params.get('oidc_error_description')
const oidcResult = params.get('oidc_result')
if (!oidcError && !oidcResult) return
if (oidcError) {
clearOIDCCallbackState('/login')
await router.replace('/login')
MessagePlugin.error(oidcErrorDescription || 'OIDC login failed')
return
}
try {
if (!oidcResult) {
clearOIDCCallbackState('/login')
await router.replace('/login')
MessagePlugin.error('OIDC login failed')
return
}
const response = decodeOIDCResult(oidcResult)
if (response.success) {
clearOIDCCallbackState('/')
await persistOIDCLoginResponse(response)
notifyLoginSuccess(response, t, tm, formatRole, roleIcon)
return
}
clearOIDCCallbackState('/login')
await router.replace('/login')
MessagePlugin.error(response.message || 'OIDC login failed')
} catch (error: any) {
console.error('Global OIDC callback handling failed:', error)
authStore.logout()
clearOIDCCallbackState('/login')
await router.replace('/login')
MessagePlugin.error(error.message || 'OIDC login failed')
}
}
let updateCheckTimer: ReturnType<typeof setInterval> | null = null
// Pending invitations poll: fires once on mount (logged-in case) and
// then every 2 minutes. Light enough to keep the avatar-row badge
// near-live without slamming the API, and avoids the cost of a
// dedicated SSE/WebSocket connection. Stopped on logout via the
// computed below.
let invitationPollTimer: ReturnType<typeof setInterval> | null = null
const INVITATION_POLL_INTERVAL_MS = 2 * 60 * 1000
const startInvitationPolling = () => {
if (invitationPollTimer || !authStore.isLoggedIn) return
// Immediate fetch so the badge is correct before the first tick.
authStore.fetchPendingInvitationCount()
invitationPollTimer = setInterval(() => {
if (!authStore.isLoggedIn) return
authStore.fetchPendingInvitationCount()
}, INVITATION_POLL_INTERVAL_MS)
}
const stopInvitationPolling = () => {
if (invitationPollTimer) {
clearInterval(invitationPollTimer)
invitationPollTimer = null
}
}
// React to login/logout via the store's isLoggedIn computed. Watching
// here (rather than only on first mount) handles the OIDC callback
// flow where the user logs in well after App.vue has already mounted.
watch(
() => authStore.isLoggedIn,
(logged) => {
if (logged) startInvitationPolling()
else stopInvitationPolling()
},
{ immediate: true },
)
// 切换租户后会 hard reload;切换前 stash 的 toast 这里 consume 并弹出,
// 这样 toast 显示在新页面上,duration 才真正生效。
const showPendingTenantSwitchToast = () => {
const pending = consumePendingTenantSwitchToast()
if (!pending) return
const templateKey = pending.role
? 'tenant.switchSuccessContentWithRole'
: 'tenant.switchSuccessContent'
// Use tm() not t() — vue-i18n v11's `t()` replaces unspecified named
// placeholders with empty strings, which would strip {name}/{role}
// before the chip renderer can split on them. tm() returns the raw
// message verbatim.
const rawTemplate = tm(templateKey)
const template = typeof rawTemplate === 'string' ? rawTemplate : ''
NotifyPlugin.success({
title: t('tenant.switchSuccessTitle'),
content: renderWorkspaceNotifyContent({
template,
name: pending.name,
roleLabel: pending.role,
roleEnum: pending.roleEnum,
roleIconName: pending.roleEnum ? roleIcon(pending.roleEnum) : undefined,
}),
duration: 6000,
closeBtn: true,
})
}
onMounted(() => {
handleGlobalOIDCCallback()
showPendingTenantSwitchToast()
// Auto check for updates on startup
setTimeout(() => {
if (settingsStore.isAutoCheckUpdateEnabled) {
// @ts-ignore
if (window.go && window.go.main && window.go.main.App && window.go.main.App.AutoCheckForUpdates) {
// @ts-ignore
window.go.main.App.AutoCheckForUpdates()
}
}
}, 2000)
// Periodically check for updates (every 4 hours)
updateCheckTimer = setInterval(() => {
if (settingsStore.isAutoCheckUpdateEnabled) {
// @ts-ignore
if (window.go && window.go.main && window.go.main.App && window.go.main.App.AutoCheckForUpdates) {
// @ts-ignore
window.go.main.App.AutoCheckForUpdates()
}
}
}, 4 * 60 * 60 * 1000)
})
onUnmounted(() => {
if (updateCheckTimer) {
clearInterval(updateCheckTimer)
}
stopInvitationPolling()
})
</script>
<template>
<t-config-provider :globalConfig="tdGlobalConfig">
<div id="app">
<RouterView />
<ManualKnowledgeEditor />
<UploadConfirmHost />
</div>
</t-config-provider>
</template>
<style>
html {
/* 提示 UA 使用对应配色绘制滚动条等,减少主题切换时的额外重绘 */
color-scheme: light dark;
}
body,
html,
#app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-size: 14px;
font-family: var(--app-font-family);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--td-bg-color-page);
color: var(--td-text-color-primary);
}
#app {
/* 独立合成层,减轻 WebKit 全量重绘时整窗与内容的撕裂感(桌面 WebView 尤其明显) */
isolation: isolate;
transform: translateZ(0);
backface-visibility: hidden;
}
</style>