feat: 完成用户相关组件的完整国际化支持

* 扩展语言文件新增用户功能翻译键
  - 新增 user.dashboard、user.login、user.management 翻译组
  - 涵盖三语言支持(zh-cn/zh-tw/en)
  - 包含120+翻译键covering用户仪表板、登录、管理功能

* UserDashboardView.vue 完整国际化
  - 集成useI18n composable
  - 国际化导航标签、统计卡片、账户信息
  - 响应式翻译Toast消息和错误处理

* UserLoginView.vue 完整国际化
  - 国际化登录表单标签、占位符、按钮文本
  - 响应式验证消息和状态提示
  - 支持动态语言切换

* UserManagementView.vue 完整国际化
  - 国际化用户列表、搜索过滤器、操作按钮
  - 响应式确认对话框和Toast通知
  - 支持参数化翻译消息(用户名、数量等)

Technical implementation:
- 遵循Vue 3 Composition API最佳实践
- 保持响应式设计和暗黑模式兼容性
- 统一错误处理和用户体验
This commit is contained in:
Wangnov
2025-09-09 11:22:55 +08:00
parent 24ad052d02
commit 27034997a6
6 changed files with 465 additions and 75 deletions

View File

@@ -3,9 +3,9 @@
<!-- Header -->
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">User Management</h1>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ t('user.management.title') }}</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
Manage users, their API keys, and view usage statistics
{{ t('user.management.description') }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
@@ -22,7 +22,7 @@
stroke-width="2"
/>
</svg>
Refresh
{{ t('user.management.refresh') }}
</button>
</div>
</div>
@@ -50,7 +50,7 @@
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Users
{{ t('user.management.totalUsers') }}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.totalUsers || 0 }}
@@ -82,7 +82,7 @@
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Active Users
{{ t('user.management.activeUsers') }}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.activeUsers || 0 }}
@@ -114,7 +114,7 @@
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total API Keys
{{ t('user.management.totalApiKeys') }}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
{{ userStats?.totalApiKeys || 0 }}
@@ -146,7 +146,7 @@
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="truncate text-sm font-medium text-gray-500 dark:text-gray-400">
Total Cost
{{ t('user.management.totalCost') }}
</dt>
<dd class="text-lg font-medium text-gray-900 dark:text-white">
${{ (userStats?.totalUsage?.totalCost || 0).toFixed(4) }}
@@ -184,7 +184,7 @@
<input
v-model="searchQuery"
class="block w-full rounded-md border-gray-300 pl-10 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
placeholder="Search users..."
:placeholder="t('user.management.searchPlaceholder')"
type="search"
@input="debouncedSearch"
/>
@@ -198,9 +198,9 @@
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
@change="loadUsers"
>
<option value="">All Roles</option>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="">{{ t('user.management.allRoles') }}</option>
<option value="user">{{ t('user.management.user') }}</option>
<option value="admin">{{ t('user.management.admin') }}</option>
</select>
</div>
@@ -211,9 +211,9 @@
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
@change="loadUsers"
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Disabled</option>
<option value="">{{ t('user.management.allStatus') }}</option>
<option value="true">{{ t('user.management.active') }}</option>
<option value="false">{{ t('user.management.disabled') }}</option>
</select>
</div>
</div>
@@ -225,7 +225,7 @@
<div class="overflow-hidden bg-white shadow dark:bg-gray-800 sm:rounded-md">
<div class="border-b border-gray-200 px-4 py-5 dark:border-gray-700 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">
Users
{{ t('user.management.users') }}
<span v-if="!loading" class="text-sm text-gray-500 dark:text-gray-400"
>({{ filteredUsers.length }} of {{ users.length }})</span
>
@@ -254,7 +254,7 @@
fill="currentColor"
></path>
</svg>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading users...</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ t('user.management.loadingUsers') }}</p>
</div>
<!-- Users List -->
@@ -299,7 +299,7 @@
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
]"
>
{{ user.isActive ? 'Active' : 'Disabled' }}
{{ user.isActive ? t('user.management.active') : t('user.management.disabled') }}
</span>
<span
:class="[
@@ -318,18 +318,18 @@
>
<span>@{{ user.username }}</span>
<span v-if="user.email">{{ user.email }}</span>
<span>{{ user.apiKeyCount || 0 }} API keys</span>
<span>{{ user.apiKeyCount || 0 }} {{ t('user.management.apiKeysCount') }}</span>
<span v-if="user.lastLoginAt"
>Last login: {{ formatDate(user.lastLoginAt) }}</span
>{{ t('user.management.lastLogin') }}: {{ formatDate(user.lastLoginAt) }}</span
>
<span v-else>Never logged in</span>
<span v-else>{{ t('user.management.neverLoggedIn') }}</span>
</div>
<div
v-if="user.totalUsage"
class="mt-1 flex items-center space-x-4 text-xs text-gray-400 dark:text-gray-500"
>
<span>{{ formatNumber(user.totalUsage.requests || 0) }} requests</span>
<span>${{ (user.totalUsage.totalCost || 0).toFixed(4) }} total cost</span>
<span>{{ formatNumber(user.totalUsage.requests || 0) }} {{ t('user.management.requests') }}</span>
<span>${{ (user.totalUsage.totalCost || 0).toFixed(4) }} {{ t('user.management.totalCostLabel') }}</span>
</div>
</div>
</div>
@@ -337,7 +337,7 @@
<!-- View Usage Stats -->
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-blue-600"
title="View Usage Stats"
:title="t('user.management.viewUsageStats')"
@click="viewUserStats(user)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -354,7 +354,7 @@
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="user.apiKeyCount === 0"
title="Disable All API Keys"
:title="t('user.management.disableAllApiKeys')"
@click="disableUserApiKeys(user)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -375,7 +375,7 @@
? 'text-gray-400 hover:text-red-600'
: 'text-gray-400 hover:text-green-600'
]"
:title="user.isActive ? 'Disable User' : 'Enable User'"
:title="user.isActive ? t('user.management.disableUser') : t('user.management.enableUser')"
@click="toggleUserStatus(user)"
>
<svg
@@ -405,7 +405,7 @@
<!-- Change Role -->
<button
class="inline-flex items-center rounded border border-transparent p-1 text-gray-400 hover:text-purple-600"
title="Change Role"
:title="t('user.management.changeRole')"
@click="changeUserRole(user)"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -437,10 +437,10 @@
stroke-width="2"
/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No users found</h3>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">{{ t('user.management.noUsersFound') }}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{
searchQuery ? 'No users match your search criteria.' : 'No users have been created yet.'
searchQuery ? t('user.management.noUsersMatch') : t('user.management.noUsersCreated')
}}
</p>
</div>
@@ -476,6 +476,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { apiClient } from '@/config/api'
import { showToast } from '@/utils/toast'
import { debounce } from 'lodash-es'
@@ -483,6 +484,7 @@ import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const { t } = useI18n()
const loading = ref(true)
const users = ref([])
const userStats = ref(null)
@@ -577,7 +579,7 @@ const loadUsers = async () => {
}
} catch (error) {
console.error('Failed to load users:', error)
showToast('Failed to load users', 'error')
showToast(t('user.management.loadUsersError'), 'error')
} finally {
loading.value = false
}
@@ -595,11 +597,11 @@ const viewUserStats = (user) => {
const toggleUserStatus = (user) => {
selectedUser.value = user
confirmAction.value = {
title: user.isActive ? 'Disable User' : 'Enable User',
title: user.isActive ? t('user.management.disableUserTitle') : t('user.management.enableUserTitle'),
message: user.isActive
? `Are you sure you want to disable user "${user.username}"? This will prevent them from logging in.`
: `Are you sure you want to enable user "${user.username}"?`,
confirmText: user.isActive ? 'Disable' : 'Enable',
? t('user.management.disableUserMessage', { username: user.username })
: t('user.management.enableUserMessage', { username: user.username }),
confirmText: user.isActive ? t('user.management.disable') : t('user.management.enable'),
confirmClass: user.isActive ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700',
action: 'toggleStatus'
}
@@ -611,9 +613,9 @@ const disableUserApiKeys = (user) => {
selectedUser.value = user
confirmAction.value = {
title: 'Disable All API Keys',
message: `Are you sure you want to disable all ${user.apiKeyCount} API keys for user "${user.username}"? This will prevent them from using the service.`,
confirmText: 'Disable Keys',
title: t('user.management.disableAllKeysTitle'),
message: t('user.management.disableAllKeysMessage', { count: user.apiKeyCount, username: user.username }),
confirmText: t('user.management.disableKeys'),
confirmClass: 'bg-red-600 hover:bg-red-700',
action: 'disableKeys'
}
@@ -640,19 +642,19 @@ const handleConfirmAction = async () => {
if (userIndex !== -1) {
users.value[userIndex].isActive = !user.isActive
}
showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
showToast(user.isActive ? t('user.management.userDisabledSuccess') : t('user.management.userEnabledSuccess'), 'success')
}
} else if (action === 'disableKeys') {
const response = await apiClient.post(`/users/${user.id}/disable-keys`)
if (response.success) {
showToast(`Disabled ${response.disabledCount} API keys`, 'success')
showToast(t('user.management.keysDisabledSuccess', { count: response.disabledCount }), 'success')
await loadUsers() // Refresh to get updated counts
}
}
} catch (error) {
console.error(`Failed to ${action}:`, error)
showToast(`Failed to ${action}`, 'error')
showToast(t(`user.management.${action}Error`), 'error')
} finally {
showConfirmModal.value = false
selectedUser.value = null