Files
Enterprise-Knowledge-Base/frontend/src/components/UserMenu.vue
T
ochan.kwon db7cef5f17 feat(i18n): replace hardcoded Chinese strings with i18n calls across frontend
Replace all user-visible hardcoded Chinese strings in Vue/TS source files
with proper i18n function calls and add corresponding translation keys
to all 4 locale files (zh-CN, en-US, ko-KR, ru-RU).

Changes:
- Replace 80+ hardcoded Chinese strings across 30+ source files
- Add ~450 new translation keys per locale file
- Cover components, views, utils, hooks, and API modules
- Remove Chinese fallback patterns (|| '中文') from existing t() calls
- Add mermaid toolbar and ollama date format translations

Intentionally preserved (backend API values / backward compatibility):
- FAQ CSV column headers and parsing constants
- RelevanceLevel type and Record key mappings
- Graph database engine status comparison ('未启用')
- Language name labels ('简体中文')
2026-03-06 10:34:08 +08:00

466 lines
13 KiB
Vue

<template>
<div class="user-menu" :class="{ 'user-menu--collapsed': uiStore.sidebarCollapsed }" ref="menuRef">
<!-- 用户按钮 -->
<div class="user-button" @click="toggleMenu">
<div class="user-avatar">
<img v-if="userAvatar" :src="userAvatar" :alt="$t('common.avatar')" />
<span v-else class="avatar-placeholder">{{ userInitial }}</span>
</div>
<template v-if="!uiStore.sidebarCollapsed">
<div class="user-info">
<div class="user-name">{{ userName }}</div>
<div class="user-email">{{ userEmail }}</div>
</div>
<t-icon :name="menuVisible ? 'chevron-up' : 'chevron-down'" class="dropdown-icon" />
</template>
</div>
<!-- 下拉菜单 -->
<Transition name="dropdown">
<div v-if="menuVisible" class="user-dropdown" @click.stop>
<div class="menu-item" @click="handleQuickNav('models')">
<t-icon name="control-platform" class="menu-icon" />
<span>{{ $t('settings.modelManagement') }}</span>
</div>
<div class="menu-item" @click="handleQuickNav('ollama')">
<t-icon name="server" class="menu-icon" />
<span>Ollama</span>
</div>
<div class="menu-item" @click="handleQuickNav('websearch')">
<svg
width="16"
height="16"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="menu-icon svg-icon"
>
<circle cx="9" cy="9" r="7" stroke="currentColor" stroke-width="1.2" fill="none"/>
<path d="M 9 2 A 3.5 7 0 0 0 9 16" stroke="currentColor" stroke-width="1.2" fill="none"/>
<path d="M 9 2 A 3.5 7 0 0 1 9 16" stroke="currentColor" stroke-width="1.2" fill="none"/>
<line x1="2.94" y1="5.5" x2="15.06" y2="5.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
<line x1="2.94" y1="12.5" x2="15.06" y2="12.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
<span>{{ $t('settings.webSearchConfig') }}</span>
</div>
<div class="menu-item" @click="handleQuickNav('mcp')">
<t-icon name="tools" class="menu-icon" />
<span>{{ $t('settings.mcpService') }}</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="handleSettings">
<t-icon name="setting" class="menu-icon" />
<span>{{ $t('general.allSettings') }}</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="openApiDoc">
<t-icon name="book" class="menu-icon" />
<span class="menu-text-with-icon">
<span>{{ $t('tenant.apiDocument') }}</span>
<svg class="menu-external-icon" viewBox="0 0 16 16" aria-hidden="true">
<path
fill="currentColor"
d="M12.667 8a.667.667 0 0 1 .666.667v4a2.667 2.667 0 0 1-2.666 2.666H4.667a2.667 2.667 0 0 1-2.667-2.666V5.333a2.667 2.667 0 0 1 2.667-2.666h4a.667.667 0 1 1 0 1.333h-4a1.333 1.333 0 0 0-1.333 1.333v7.334A1.333 1.333 0 0 0 4.667 13.333h6a1.333 1.333 0 0 0 1.333-1.333v-4A.667.667 0 0 1 12.667 8Zm2.666-6.667v4a.667.667 0 0 1-1.333 0V3.276l-5.195 5.195a.667.667 0 0 1-.943-.943l5.195-5.195h-2.057a.667.667 0 0 1 0-1.333h4a.667.667 0 0 1 .666.666Z"
/>
</svg>
</span>
</div>
<div class="menu-item" @click="openWebsite">
<t-icon name="home" class="menu-icon" />
<span class="menu-text-with-icon">
<span>{{ $t('common.website') }}</span>
<svg class="menu-external-icon" viewBox="0 0 16 16" aria-hidden="true">
<path
fill="currentColor"
d="M12.667 8a.667.667 0 0 1 .666.667v4a2.667 2.667 0 0 1-2.666 2.666H4.667a2.667 2.667 0 0 1-2.667-2.666V5.333a2.667 2.667 0 0 1 2.667-2.666h4a.667.667 0 1 1 0 1.333h-4a1.333 1.333 0 0 0-1.333 1.333v7.334A1.333 1.333 0 0 0 4.667 13.333h6a1.333 1.333 0 0 0 1.333-1.333v-4A.667.667 0 0 1 12.667 8Zm2.666-6.667v4a.667.667 0 0 1-1.333 0V3.276l-5.195 5.195a.667.667 0 0 1-.943-.943l5.195-5.195h-2.057a.667.667 0 0 1 0-1.333h4a.667.667 0 0 1 .666.666Z"
/>
</svg>
</span>
</div>
<div class="menu-item" @click="openGithub">
<t-icon name="logo-github" class="menu-icon" />
<span class="menu-text-with-icon">
<span>GitHub</span>
<svg class="menu-external-icon" viewBox="0 0 16 16" aria-hidden="true">
<path
fill="currentColor"
d="M12.667 8a.667.667 0 0 1 .666.667v4a2.667 2.667 0 0 1-2.666 2.666H4.667a2.667 2.667 0 0 1-2.667-2.666V5.333a2.667 2.667 0 0 1 2.667-2.666h4a.667.667 0 1 1 0 1.333h-4a1.333 1.333 0 0 0-1.333 1.333v7.334A1.333 1.333 0 0 0 4.667 13.333h6a1.333 1.333 0 0 0 1.333-1.333v-4A.667.667 0 0 1 12.667 8Zm2.666-6.667v4a.667.667 0 0 1-1.333 0V3.276l-5.195 5.195a.667.667 0 0 1-.943-.943l5.195-5.195h-2.057a.667.667 0 0 1 0-1.333h4a.667.667 0 0 1 .666.666Z"
/>
</svg>
</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item danger" @click="handleLogout">
<t-icon name="logout" class="menu-icon" />
<span>{{ $t('auth.logout') }}</span>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUIStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth'
import { MessagePlugin } from 'tdesign-vue-next'
import { getCurrentUser, logout as logoutApi } from '@/api/auth'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter()
const uiStore = useUIStore()
const authStore = useAuthStore()
const menuRef = ref<HTMLElement>()
const menuVisible = ref(false)
// 用户信息
const userInfo = ref({
username: t('common.defaultUser'),
email: 'user@example.com',
avatar: ''
})
const userName = computed(() => userInfo.value.username)
const userEmail = computed(() => userInfo.value.email)
const userAvatar = computed(() => userInfo.value.avatar)
// 用户名首字母(用于无头像时显示)
const userInitial = computed(() => {
return userName.value.charAt(0).toUpperCase()
})
// 切换菜单显示
const toggleMenu = () => {
menuVisible.value = !menuVisible.value
}
// 快捷导航到设置的特定部分
const handleQuickNav = (section: string) => {
menuVisible.value = false
uiStore.openSettings()
router.push('/platform/settings')
// 延迟一下,确保设置页面已经渲染
setTimeout(() => {
// 触发设置页面切换到对应section
const event = new CustomEvent('settings-nav', { detail: { section } })
window.dispatchEvent(event)
}, 100)
}
// 打开设置
const handleSettings = () => {
menuVisible.value = false
uiStore.openSettings()
router.push('/platform/settings')
}
// 打开 API 文档
const openApiDoc = () => {
menuVisible.value = false
window.open('https://github.com/Tencent/WeKnora/blob/main/docs/api/README.md', '_blank')
}
// 打开官网
const openWebsite = () => {
menuVisible.value = false
window.open('https://weknora.weixin.qq.com/', '_blank')
}
// 打开 GitHub
const openGithub = () => {
menuVisible.value = false
window.open('https://github.com/Tencent/WeKnora', '_blank')
}
// 注销
const handleLogout = async () => {
menuVisible.value = false
try {
// 调用后端API注销
await logoutApi()
} catch (error) {
// 即使API调用失败,也继续执行本地清理
console.error('注销API调用失败:', error)
}
// 清理所有状态和本地存储
authStore.logout()
MessagePlugin.success(t('auth.logout'))
// 跳转到登录页
router.push('/login')
}
// 加载用户信息
const loadUserInfo = async () => {
try {
const response = await getCurrentUser()
if (response.success && response.data && response.data.user) {
const user = response.data.user
userInfo.value = {
username: user.username || t('common.info'),
email: user.email || 'user@example.com',
avatar: user.avatar || ''
}
// 同时更新 authStore 中的用户信息,确保包含 can_access_all_tenants 字段
authStore.setUser({
id: user.id,
username: user.username,
email: user.email,
avatar: user.avatar,
tenant_id: user.tenant_id,
can_access_all_tenants: user.can_access_all_tenants || false,
created_at: user.created_at,
updated_at: user.updated_at
})
// 如果返回了租户信息,也更新租户信息
if (response.data.tenant) {
authStore.setTenant({
id: String(response.data.tenant.id),
name: response.data.tenant.name,
api_key: response.data.tenant.api_key || '',
owner_id: user.id,
created_at: response.data.tenant.created_at,
updated_at: response.data.tenant.updated_at
})
}
}
} catch (error) {
console.error('Failed to load user info:', error)
}
}
// 点击外部关闭菜单
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
menuVisible.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
loadUserInfo()
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style lang="less" scoped>
.user-menu {
position: relative;
width: 100%;
&--collapsed {
.user-button {
justify-content: center;
padding: 8px;
gap: 0;
}
.user-avatar {
width: 32px;
height: 32px;
.avatar-placeholder {
font-size: 13px;
}
}
.user-dropdown {
left: calc(100% + 8px);
bottom: 0;
right: auto;
min-width: 200px;
}
}
}
.user-button {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
background: transparent;
&:hover {
background: var(--td-bg-color-container-hover);
}
&:active {
transform: scale(0.98);
}
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, var(--td-brand-color) 0%, var(--td-brand-color-active) 100%);
display: flex;
align-items: center;
justify-content: center;
transition: width 0.2s ease, height 0.2s ease;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
color: var(--td-text-color-anti);
font-size: 16px;
font-weight: 600;
}
}
.user-info {
flex: 1;
min-width: 0;
text-align: left;
.user-name {
font-size: 14px;
font-weight: 500;
color: var(--td-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-email {
font-size: 12px;
color: var(--td-text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.dropdown-icon {
font-size: 16px;
color: var(--td-text-color-secondary);
flex-shrink: 0;
transition: transform 0.2s;
}
.user-dropdown {
position: absolute;
bottom: 100%;
left: 8px;
right: 8px;
margin-bottom: 8px;
background: var(--td-bg-color-container);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
border: 1px solid var(--td-component-stroke);
overflow: hidden;
z-index: 1000;
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
color: var(--td-text-color-primary);
&:hover {
background: var(--td-bg-color-container-hover);
}
&.danger {
color: var(--td-error-color);
&:hover {
background: var(--td-error-color-light);
}
.menu-icon {
color: var(--td-error-color);
}
}
.menu-icon {
font-size: 16px;
color: var(--td-text-color-secondary);
&.svg-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.menu-text-with-icon {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
color: inherit;
min-width: 0;
span {
display: inline-flex;
align-items: center;
min-width: 0;
}
}
.menu-external-icon {
width: 14px;
height: 14px;
color: var(--td-text-color-disabled);
flex-shrink: 0;
transition: color 0.2s ease;
pointer-events: none;
}
&:hover .menu-external-icon {
color: var(--td-brand-color);
}
}
.menu-divider {
height: 1px;
background: var(--td-component-stroke);
margin: 4px 0;
}
// 下拉动画
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(8px);
}
.dropdown-enter-to,
.dropdown-leave-from {
opacity: 1;
transform: translateY(0);
}
</style>