fix(frontend): complete i18n coverage and responsive layouts

This commit is contained in:
elky
2026-09-07 08:54:19 +08:00
parent 14f96c9fa0
commit b599fb7354
78 changed files with 4809 additions and 1485 deletions
+24 -9
View File
@@ -131,7 +131,10 @@
<TableCell class="py-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-foreground">{{ announcement.title }}</span>
<span
translate="no"
class="text-sm font-medium text-foreground"
>{{ announcement.title }}</span>
<Badge
v-if="announcement.requires_ack"
variant="outline"
@@ -144,7 +147,10 @@
class="w-3.5 h-3.5 text-muted-foreground flex-shrink-0"
/>
</div>
<p class="text-xs text-muted-foreground line-clamp-1">
<p
translate="no"
class="text-xs text-muted-foreground line-clamp-1"
>
{{ getPlainText(announcement.content) }}
</p>
</div>
@@ -246,7 +252,10 @@
class="w-4 h-4 shrink-0"
:class="getIconColor(announcement.type)"
/>
<span class="font-medium text-sm">{{ announcement.title }}</span>
<span
translate="no"
class="font-medium text-sm"
>{{ announcement.title }}</span>
<Badge
v-if="announcement.requires_ack"
variant="outline"
@@ -266,7 +275,10 @@
{{ announcement.is_read ? '已读' : '未读' }}
</Badge>
</div>
<p class="text-xs text-muted-foreground line-clamp-2">
<p
translate="no"
class="text-xs text-muted-foreground line-clamp-2"
>
{{ getPlainText(announcement.content) }}
</p>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
@@ -555,6 +567,7 @@
<!-- eslint-disable vue/no-v-html -->
<div
translate="no"
class="prose prose-sm dark:prose-invert max-w-none"
v-html="renderMarkdown(viewingAnnouncement.content)"
/>
@@ -576,6 +589,8 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { formatRelativeTime } from '@/utils/format'
import { ref, onMounted, computed } from 'vue'
import { announcementApi, type Announcement } from '@/api/announcements'
import { useAuthStore } from '@/stores/auth'
@@ -852,7 +867,7 @@ function getDialogIconClass(type?: string) {
function formatFullDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
return date.toLocaleDateString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -884,13 +899,13 @@ function formatDate(dateString: string): string {
const minutes = Math.floor(diff / (1000 * 60))
if (minutes < 60) {
return `${minutes} 分钟前`
return minutes < 1 ? formatRelativeTime(0, 'second') : formatRelativeTime(-minutes, 'minute')
} else if (hours < 24) {
return `${hours} 小时前`
return formatRelativeTime(-hours, 'hour')
} else if (days < 7) {
return `${days} 天前`
return formatRelativeTime(-days, 'day')
} else {
return date.toLocaleDateString('zh-CN', {
return date.toLocaleDateString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit'
+2 -2
View File
@@ -215,7 +215,7 @@ import {
import { EmptyState, LoadingState, StripePaymentDialog } from '@/components/common'
import { CardSection, PageContainer, PageHeader } from '@/components/layout'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { getI18nLocale, useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import {
entitlementReplacementGroups,
@@ -521,6 +521,6 @@ function formatDuration(unit: BillingDurationUnit, value: number): string {
function formatDate(value: string | null | undefined): string {
if (!value) return '-'
return new Date(value).toLocaleDateString('zh-CN')
return new Date(value).toLocaleDateString(getI18nLocale())
}
</script>
+3 -2
View File
@@ -672,6 +672,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
@@ -1177,7 +1178,7 @@ async function copyToken(text: string) {
// 格式化
function formatNumber(num: number): string {
return num.toLocaleString('zh-CN')
return num.toLocaleString(getI18nLocale())
}
function toLocalDatetimeString(date: Date): string {
@@ -1191,7 +1192,7 @@ function toLocalDatetimeString(date: Date): string {
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
return date.toLocaleDateString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit'
+3 -2
View File
@@ -912,6 +912,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, onMounted, onBeforeUnmount, computed, watch, reactive } from 'vue'
import { meApi, type ApiKey, type InstallSessionTargetSystem, type InstallTargetCli, type ApiKeyInstallSession } from '@/api/me'
import Card from '@/components/ui/card.vue'
@@ -1598,7 +1599,7 @@ function formatNumber(num: number | undefined | null): string {
if (num === undefined || num === null) {
return '0'
}
return num.toLocaleString('zh-CN')
return num.toLocaleString(getI18nLocale())
}
function formatConcurrentLimitSimple(concurrentLimit?: number | null): string {
@@ -1624,7 +1625,7 @@ function formatDate(dateString?: string | null): string {
if (!dateString) return '未知'
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleDateString('zh-CN', {
return date.toLocaleDateString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit'
+29 -17
View File
@@ -13,7 +13,7 @@
class="space-y-4"
@submit.prevent="updateProfile"
>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-lg font-medium text-foreground">
基本信息
</h3>
@@ -82,8 +82,8 @@
</Card>
<Card class="p-6">
<div class="flex items-center justify-between mb-4">
<div>
<div class="flex flex-col items-start gap-3 mb-4 sm:flex-row sm:justify-between">
<div class="min-w-0 flex-1">
<h3 class="text-lg font-medium text-foreground">
敏感信息保护
</h3>
@@ -93,6 +93,7 @@
</div>
<Button
variant="outline"
class="shrink-0"
:disabled="savingFeatureSettings || !hasFeatureSettingsChanges"
@click="updateFeatureSettings"
>
@@ -152,7 +153,7 @@
class="space-y-4"
@submit.prevent="changePassword"
>
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-lg font-medium text-foreground">
{{ profile?.has_password ? '修改密码' : '设置密码' }}
</h3>
@@ -198,7 +199,7 @@
</p>
</div>
<div>
<Label for="confirm-password">确认{{ profile?.has_password ? '新' : '' }}密码</Label>
<Label for="confirm-password">{{ profile?.has_password ? '确认新密码' : '确认密码' }}</Label>
<Input
id="confirm-password"
v-model="passwordForm.confirm_password"
@@ -218,8 +219,8 @@
</Card>
<Card class="p-6">
<div class="flex items-center justify-between mb-4">
<div>
<div class="flex flex-col items-start gap-3 mb-4 sm:flex-row sm:justify-between">
<div class="min-w-0 flex-1">
<h3 class="text-lg font-medium text-foreground">
登录设备
</h3>
@@ -229,6 +230,7 @@
</div>
<Button
variant="outline"
class="shrink-0"
:disabled="sessionsLoading || otherSessionCount === 0 || sessionActionLoading === 'others'"
@click="handleRevokeOtherSessions"
>
@@ -255,7 +257,7 @@
<div
v-for="session in userSessions"
:key="session.id"
class="flex items-start justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 p-4"
class="flex min-w-0 flex-col items-start gap-3 rounded-lg border border-border/60 bg-muted/20 p-4 sm:flex-row sm:justify-between"
>
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
@@ -263,14 +265,15 @@
<Input
v-model="sessionLabelDraft"
size="sm"
class="h-8 w-56"
class="h-8 w-full sm:w-56"
maxlength="120"
@keyup.enter="saveSessionLabel(session.id)"
/>
</template>
<span
v-else
class="font-medium text-foreground"
translate="no"
class="break-words font-medium text-foreground"
>{{ session.device_label }}</span>
<Badge
v-if="session.is_current"
@@ -287,7 +290,7 @@
<span v-if="session.ip_address"> · IP {{ session.ip_address }}</span>
</p>
</div>
<div class="flex items-center gap-2">
<div class="flex shrink-0 flex-wrap items-center gap-2">
<template v-if="editingSessionId === session.id">
<Button
size="sm"
@@ -482,7 +485,7 @@
<SelectItem value="zh-CN">
简体中文
</SelectItem>
<SelectItem value="en">
<SelectItem value="en-US">
English
</SelectItem>
</SelectContent>
@@ -654,7 +657,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { getI18nLocale, normalizeLocale, useI18n } from '@/i18n'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { meApi, type Profile } from '@/api/me'
@@ -698,6 +702,7 @@ const route = useRoute()
const router = useRouter()
const { success, error: showError } = useToast()
const { setThemeMode } = useDarkMode()
const { locale, setLocale } = useI18n()
const profile = ref<Profile | null>(null)
const userSessions = ref<UserSession[]>([])
@@ -722,7 +727,7 @@ const preferencesForm = ref({
avatar_url: '',
bio: '',
theme: 'light',
language: 'zh-CN',
language: locale.value,
timezone: 'Asia/Shanghai',
notifications: {
email: true,
@@ -805,11 +810,18 @@ function handleThemeChange(value: string) {
}
function handleLanguageChange(value: string) {
preferencesForm.value.language = value
const nextLocale = normalizeLocale(value)
if (!nextLocale) return
preferencesForm.value.language = nextLocale
setLocale(nextLocale)
languageSelectOpen.value = false
updatePreferences()
}
watch(locale, value => {
preferencesForm.value.language = value
})
onMounted(async () => {
const profilePromise = loadProfile()
await Promise.all([
@@ -999,7 +1011,7 @@ async function loadPreferences() {
avatar_url: prefs.avatar_url || '',
bio: prefs.bio || '',
theme: localTheme, // 使用本地主题,而非服务端返回值
language: prefs.language || 'zh-CN',
language: locale.value,
timezone: prefs.timezone || 'Asia/Shanghai',
notifications: {
email: prefs.notifications?.email ?? true,
@@ -1193,7 +1205,7 @@ function isUnlimitedBilling(): boolean {
function formatDate(dateString?: string): string {
if (!dateString) return '未知'
return new Date(dateString).toLocaleDateString('zh-CN', {
return new Date(dateString).toLocaleDateString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
+2 -1
View File
@@ -682,6 +682,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import {
Badge,
@@ -1327,7 +1328,7 @@ function handleRefundPageSizeChange(size: number) {
function formatDateTime(value: string | null | undefined): string {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', {
return new Date(value).toLocaleString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -0,0 +1,229 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App, type ComputedRef } from 'vue'
import { getI18nLocale, setI18nLocale } from '@/i18n'
import Settings from '../Settings.vue'
const meApiMock = vi.hoisted(() => ({
getProfile: vi.fn(),
getPreferences: vi.fn(),
listSessions: vi.fn(),
updatePreferences: vi.fn(),
}))
const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
vi.mock('@/api/me', () => ({ meApi: meApiMock }))
vi.mock('@/api/auth', () => ({
authApi: { getRegistrationSettings: vi.fn().mockResolvedValue({ email_configured: false }) },
}))
vi.mock('@/api/oauth', () => ({ oauthApi: {} }))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({ fetchCurrentUser: vi.fn(), logout: vi.fn() }),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ fullPath: '/dashboard/settings' }),
useRouter: () => ({ replace: vi.fn() }),
}))
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMock }))
vi.mock('@/composables/useDarkMode', async () => {
const { ref } = await import('vue')
return { useDarkMode: () => ({ themeMode: ref('light'), setThemeMode: vi.fn() }) }
})
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn(), info: vi.fn() } }))
interface SelectContext {
value: ComputedRef<string | undefined>
select: (value: string) => void
}
// Keep the Select model/event contract while making its options directly
// accessible without Radix's portal and pointer-event requirements in jsdom.
vi.mock('@/components/ui/select.vue', async () => {
const { computed, defineComponent, h, provide } = await import('vue')
return {
default: defineComponent({
props: { modelValue: String, open: Boolean },
emits: ['update:modelValue', 'update:open'],
setup(props, { emit, slots }) {
provide<SelectContext>('settings-test-select', {
value: computed(() => props.modelValue),
select: value => emit('update:modelValue', value),
})
return () => h('div', { 'data-select-value': props.modelValue }, slots.default?.())
},
}),
}
})
vi.mock('@/components/ui/select-trigger.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ setup: (_, { slots }) => () => h('button', { type: 'button' }, slots.default?.()) }) }
})
vi.mock('@/components/ui/select-value.vue', async () => {
const { defineComponent, h, inject } = await import('vue')
return {
default: defineComponent({
setup() {
const select = inject<SelectContext>('settings-test-select')
return () => h('span', select?.value.value)
},
}),
}
})
vi.mock('@/components/ui/select-content.vue', async () => {
const { defineComponent, h } = await import('vue')
return { default: defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) }) }
})
vi.mock('@/components/ui/select-item.vue', async () => {
const { defineComponent, h, inject } = await import('vue')
return {
default: defineComponent({
props: { value: { type: String, required: true } },
setup(props, { slots }) {
const select = inject<SelectContext>('settings-test-select')
return () => h('button', {
type: 'button',
role: 'option',
'data-option-value': props.value,
'aria-selected': select?.value.value === props.value,
onClick: () => select?.select(props.value),
}, slots.default?.())
},
}),
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function serverPreferences() {
return {
theme: 'light',
language: 'zh-CN',
timezone: 'Asia/Shanghai',
notifications: { email: true, usage_alerts: true, announcements: true },
}
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(complete => { resolve = complete })
return { promise, resolve }
}
async function flushPromises() {
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))
await nextTick()
}
function mountSettings() {
const root = document.createElement('div')
document.body.append(root)
const app = createApp(Settings)
app.mount(root)
mountedApps.push({ app, root })
return root
}
function languageSelect(root: HTMLElement): HTMLElement {
const select = root.querySelector('#language')?.closest<HTMLElement>('[data-select-value]')
if (!select) throw new Error('The settings language select was not rendered')
return select
}
function chooseEnglish(root: HTMLElement) {
const option = languageSelect(root).querySelector<HTMLButtonElement>('[data-option-value="en-US"]')
if (!option) throw new Error('The English option was not rendered')
expect(option.textContent?.trim()).toBe('English')
option.click()
}
beforeEach(() => {
vi.clearAllMocks()
setI18nLocale('zh-CN')
meApiMock.getProfile.mockResolvedValue({
id: 'settings-user', username: 'User', role: 'user', is_active: true,
auth_source: 'ldap', feature_settings: {},
})
meApiMock.listSessions.mockResolvedValue([])
meApiMock.getPreferences.mockResolvedValue(serverPreferences())
meApiMock.updatePreferences.mockResolvedValue(undefined)
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('Settings language preferences', () => {
it('initializes from the active locale and preserves it when old server preferences arrive', async () => {
const preferences = deferred<ReturnType<typeof serverPreferences>>()
meApiMock.getPreferences.mockReturnValueOnce(preferences.promise)
setI18nLocale('en-US')
const root = mountSettings()
expect(languageSelect(root).dataset.selectValue).toBe('en-US')
preferences.resolve(serverPreferences())
await flushPromises()
expect(languageSelect(root).dataset.selectValue).toBe('en-US')
expect(languageSelect(root).querySelector('[data-option-value="en-US"]')?.getAttribute('aria-selected')).toBe('true')
expect(getI18nLocale()).toBe('en-US')
expect(localStorage.getItem('aether_locale')).toBe('en-US')
})
it('switches locale and persists en-US immediately on the Select update event', async () => {
const save = deferred<void>()
meApiMock.updatePreferences.mockReturnValueOnce(save.promise)
const root = mountSettings()
await flushPromises()
expect(languageSelect(root).dataset.selectValue).toBe('zh-CN')
chooseEnglish(root)
expect(getI18nLocale()).toBe('en-US')
expect(document.documentElement.lang).toBe('en-US')
expect(localStorage.getItem('aether_locale')).toBe('en-US')
expect(meApiMock.updatePreferences).toHaveBeenCalledWith(expect.objectContaining({ language: 'en-US' }))
expect(toastMock.success).not.toHaveBeenCalled()
await nextTick()
expect(languageSelect(root).dataset.selectValue).toBe('en-US')
save.resolve()
await flushPromises()
expect(toastMock.success).toHaveBeenCalled()
expect(toastMock.error).not.toHaveBeenCalled()
})
it('does not undo a new selection when the initial preference request completes late', async () => {
const preferences = deferred<ReturnType<typeof serverPreferences>>()
meApiMock.getPreferences.mockReturnValueOnce(preferences.promise)
const root = mountSettings()
chooseEnglish(root)
preferences.resolve(serverPreferences())
await flushPromises()
expect(getI18nLocale()).toBe('en-US')
expect(languageSelect(root).dataset.selectValue).toBe('en-US')
expect(meApiMock.updatePreferences).toHaveBeenCalledTimes(1)
expect(meApiMock.updatePreferences).toHaveBeenCalledWith(expect.objectContaining({ language: 'en-US' }))
})
it('keeps its selected option synchronized with language changes from the top bar', async () => {
const root = mountSettings()
await flushPromises()
setI18nLocale('en-US')
await nextTick()
expect(languageSelect(root).dataset.selectValue).toBe('en-US')
expect(languageSelect(root).querySelector('[data-option-value="en-US"]')?.getAttribute('aria-selected')).toBe('true')
setI18nLocale('zh-CN')
await nextTick()
expect(languageSelect(root).dataset.selectValue).toBe('zh-CN')
expect(languageSelect(root).querySelector('[data-option-value="zh-CN"]')?.getAttribute('aria-selected')).toBe('true')
expect(meApiMock.updatePreferences).not.toHaveBeenCalled()
})
})