refactor(frontend): modularize i18n architecture

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent bf29129a4b
commit 9f138d09e6
118 changed files with 11940 additions and 5368 deletions
@@ -13,7 +13,7 @@
class="h-16 w-16 mb-4"
>
<h2 class="text-2xl font-semibold text-foreground">
登录到 {{ siteName }}
{{ t('auth.login.title', { siteName }) }}
</h2>
</div>
@@ -23,7 +23,7 @@
class="rounded-lg border border-primary/20 bg-primary/5 p-3 mb-5"
>
<p class="text-xs font-medium text-foreground mb-2">
演示模式
{{ t('auth.login.demoMode') }}
</p>
<div class="space-y-1.5">
<button
@@ -66,7 +66,7 @@
v-html="getOAuthIcon(oauthProviders[0].provider_type, oauthProviders[0].icon_url)"
/>
<!-- eslint-enable vue/no-v-html -->
<span>使用 {{ oauthProviders[0].display_name }} 登录</span>
<span>{{ t('auth.login.oauthWithProvider', { provider: oauthProviders[0].display_name }) }}</span>
</button>
</div>
@@ -75,7 +75,7 @@
v-else
class="flex flex-col items-center gap-3"
>
<span class="text-xs text-muted-foreground">使用以下方式登录</span>
<span class="text-xs text-muted-foreground">{{ t('auth.login.oauthOptions') }}</span>
<div class="flex items-center justify-center gap-3">
<button
v-for="p in oauthProviders"
@@ -102,7 +102,7 @@
class="flex items-center gap-3 mb-5"
>
<div class="flex-1 h-px bg-border" />
<span class="text-xs text-muted-foreground px-2">或使用账号密码</span>
<span class="text-xs text-muted-foreground px-2">{{ t('auth.login.accountPassword') }}</span>
<div class="flex-1 h-px bg-border" />
</div>
@@ -117,7 +117,7 @@
:class="[authType === 'local' && 'active']"
@click="authType = 'local'"
>
本地登录
{{ t('auth.login.local') }}
</button>
<button
type="button"
@@ -125,7 +125,7 @@
:class="[authType === 'ldap' && 'active']"
@click="authType = 'ldap'"
>
LDAP 登录
{{ t('auth.login.ldap') }}
</button>
</div>
@@ -154,7 +154,7 @@
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
@click="authType = 'local'"
>
管理员本地登录
{{ t('auth.login.adminLocal') }}
</button>
<button
v-if="ldapExclusive && authType === 'local'"
@@ -162,7 +162,7 @@
class="text-xs text-muted-foreground/60 hover:text-muted-foreground transition-colors"
@click="authType = 'ldap'"
>
返回 LDAP 登录
{{ t('auth.login.backToLdap') }}
</button>
</div>
<Input
@@ -171,7 +171,7 @@
type="text"
name="username"
required
placeholder="用户名或邮箱"
:placeholder="t('auth.login.usernamePlaceholder')"
autocomplete="username"
autocapitalize="none"
spellcheck="false"
@@ -184,7 +184,7 @@
for="password"
class="text-sm"
>
密码
{{ t('auth.login.password') }}
</Label>
<Input
id="password"
@@ -192,7 +192,7 @@
type="password"
name="password"
required
placeholder="输入密码"
:placeholder="t('auth.login.passwordPlaceholder')"
autocomplete="current-password"
:disable-autofill="false"
/>
@@ -204,7 +204,7 @@
:disabled="authStore.loading"
class="w-full h-12"
>
{{ authStore.loading ? '登录中...' : '登录' }}
{{ authStore.loading ? t('auth.login.submitting') : t('auth.login.submit') }}
</Button>
<!-- 提示信息 -->
@@ -212,7 +212,7 @@
v-if="!isDemo && !allowRegistration"
class="text-xs text-muted-foreground text-center"
>
如需开通账户,请联系管理员
{{ t('auth.login.contactAdmin') }}
</p>
</form>
@@ -221,13 +221,13 @@
v-if="allowRegistration"
class="mt-5 pt-5 border-t border-border text-center text-sm text-muted-foreground"
>
还没有账户?
{{ t('auth.login.noAccount') }}
<button
type="button"
class="text-primary hover:text-primary/80 font-medium transition-colors"
@click="handleSwitchToRegister"
>
立即注册
{{ t('auth.login.registerNow') }}
</button>
</div>
</div>
@@ -266,6 +266,7 @@ import { getClientDeviceId } from '@/utils/deviceId'
import { getApiUrl } from '@/utils/url'
import { getOAuthIcon } from '@/utils/oauth-icons'
import { navigateAfterLogin } from '@/features/auth/utils/loginRedirect'
import { useI18n } from '@/i18n'
const props = defineProps<{
modelValue: boolean
@@ -280,6 +281,7 @@ const route = useRoute()
const authStore = useAuthStore()
const { success: showSuccess, warning: showWarning, error: showError } = useToast()
const { siteName } = useSiteInfo()
const { t } = useI18n()
const isOpen = ref(props.modelValue)
const isDemo = computed(() => isDemoMode())
@@ -321,7 +323,7 @@ const showAuthTypeTabs = computed(() => {
})
const emailLabel = computed(() => {
return '用户名/邮箱'
return t('auth.login.usernameEmail')
})
watch(() => props.modelValue, (val) => {
@@ -354,7 +356,7 @@ async function handleLogin(event?: Event) {
const { email, password } = readCurrentLoginCredentials(event)
if (!email || !password) {
showWarning('请输入邮箱和密码')
showWarning(t('auth.login.required'))
return
}
@@ -364,12 +366,12 @@ async function handleLogin(event?: Event) {
await navigateAfterLogin(router, targetPath)
showSuccess('登录成功,正在跳转...')
showSuccess(t('auth.login.successRedirecting'))
// 关闭对话框
isOpen.value = false
} else {
showError(authStore.error || '登录失败,请检查邮箱和密码')
showError(authStore.error || t('auth.login.failed'))
}
}
@@ -423,7 +425,7 @@ function handleSwitchToRegister() {
function handleRegisterSuccess() {
showRegisterDialog.value = false
showSuccess('注册成功!请登录')
showSuccess(t('auth.register.successLogin'))
isOpen.value = true
}
@@ -14,10 +14,10 @@
>
</div>
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
注册新账户
{{ registerUi.title }}
</h2>
<p class="mt-1 text-sm text-muted-foreground">
{{ emailConfigured ? '请填写您的信息完成注册' : '请填写用户名和密码完成注册' }}
{{ emailConfigured ? registerUi.fillEmailInfo : registerUi.fillBasicInfo }}
</p>
</div>
@@ -34,7 +34,7 @@
class="space-y-2"
>
<Label for="reg-email">
邮箱
{{ registerUi.email }}
<span
v-if="requireEmailVerification"
class="text-destructive"
@@ -42,7 +42,7 @@
<span
v-else
class="text-muted-foreground text-xs"
>(可选)</span>
>{{ registerUi.optional }}</span>
</Label>
<Input
id="reg-email"
@@ -59,7 +59,7 @@
v-if="turnstileRequired"
class="space-y-2"
>
<Label>人机验证 <span class="text-destructive">*</span></Label>
<Label>{{ registerUi.turnstile }} <span class="text-destructive">*</span></Label>
<TurnstileWidget
ref="turnstileWidgetRef"
v-model="turnstileToken"
@@ -76,7 +76,7 @@
class="space-y-3"
>
<div class="flex items-center justify-between">
<Label>验证码 <span class="text-destructive">*</span></Label>
<Label>{{ registerUi.verificationCode }} <span class="text-destructive">*</span></Label>
<Button
type="button"
variant="link"
@@ -143,12 +143,12 @@
<!-- Username -->
<div class="space-y-2">
<Label for="reg-uname">用户名 <span class="text-destructive">*</span></Label>
<Label for="reg-uname">{{ registerUi.username }} <span class="text-destructive">*</span></Label>
<Input
id="reg-uname"
v-model="formData.username"
type="text"
placeholder="请输入用户名"
:placeholder="registerUi.usernamePlaceholder"
required
disable-autofill
:disabled="isLoading"
@@ -164,7 +164,9 @@
<!-- Password -->
<div class="space-y-2">
<Label :for="`pwd-${formNonce}`">密码 <span class="text-destructive">*</span></Label>
<Label :for="`pwd-${formNonce}`">
{{ registerUi.password }} <span class="text-destructive">*</span>
</Label>
<Input
:id="`pwd-${formNonce}`"
v-model="formData.password"
@@ -192,7 +194,7 @@
<!-- Confirm Password -->
<div class="space-y-2">
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-destructive">*</span></Label>
<Label :for="`pwd-confirm-${formNonce}`">{{ registerUi.confirmPassword }} <span class="text-destructive">*</span></Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="formData.confirmPassword"
@@ -200,7 +202,7 @@
autocomplete="new-password"
disable-autofill
:name="`pwd-confirm-${formNonce}`"
placeholder="再次输入密码"
:placeholder="registerUi.confirmPasswordPlaceholder"
required
:disabled="isLoading"
/>
@@ -208,7 +210,7 @@
v-if="formData.confirmPassword && formData.password !== formData.confirmPassword"
class="text-xs text-destructive"
>
两次输入的密码不一致
{{ registerUi.passwordMismatch }}
</p>
</div>
@@ -216,7 +218,7 @@
v-if="inviteCode"
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-muted-foreground"
>
已识别邀请码 <span class="font-mono font-semibold text-foreground">{{ inviteCode }}</span>
{{ inviteCodeText }}
</div>
<div
@@ -230,38 +232,38 @@
@update:checked="privacyAccepted = !!$event"
/>
<span class="leading-6">
我已阅读并同意
{{ registerUi.privacyPrefix }}
<button
type="button"
class="font-medium text-primary underline-offset-4 hover:underline"
@click="privacyDialogOpen = true"
>
隐私政策
{{ registerUi.privacyTitle }}
</button>
<RouterLink
to="/privacy-policy"
target="_blank"
class="ml-1 text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
新窗口打开
{{ registerUi.openInNewWindow }}
</RouterLink>
</span>
</label>
<p class="mt-2 text-xs text-muted-foreground">
当前版本:{{ privacyPolicyVersion }}
{{ privacyVersionText }}
</p>
</div>
</form>
<!-- 登录链接 -->
<div class="text-center text-sm">
已有账户?
{{ registerUi.hasAccount }}
<Button
variant="link"
class="h-auto p-0"
@click="handleSwitchToLogin"
>
立即登录
{{ registerUi.switchToLogin }}
</Button>
</div>
</div>
@@ -274,14 +276,14 @@
:disabled="isLoading"
@click="handleCancel"
>
取消
{{ registerUi.cancel }}
</Button>
<Button
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
:disabled="isLoading || !canSubmit"
@click="handleSubmit"
>
{{ isLoading ? loadingText : '注册' }}
{{ isLoading ? loadingText : registerUi.submit }}
</Button>
</template>
</Dialog>
@@ -289,7 +291,7 @@
<Dialog
v-model="privacyDialogOpen"
size="2xl"
title="隐私政策"
:title="registerUi.privacyTitle"
>
<!-- eslint-disable vue/no-v-html -->
<div
@@ -302,7 +304,7 @@
type="button"
@click="privacyDialogOpen = false"
>
我知道了
{{ registerUi.acknowledge }}
</Button>
</template>
</Dialog>
@@ -314,6 +316,7 @@ import { RouterLink } from 'vue-router'
import { marked } from 'marked'
import { authApi, type RegisterRequest, type RegistrationPrivacyPolicySettings } from '@/api/auth'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { parseApiError } from '@/utils/errorParser'
import { sanitizeHtml, sanitizeMarkdown } from '@/utils/sanitize'
import {
@@ -365,6 +368,35 @@ interface Emits {
}
const { success, error: showError } = useToast()
const { t } = useI18n()
const registerUi = computed(() => ({
title: t('auth.register.title'),
fillEmailInfo: t('auth.register.fillEmailInfo'),
fillBasicInfo: t('auth.register.fillBasicInfo'),
email: t('auth.register.email'),
optional: t('auth.register.optional'),
turnstile: t('auth.register.turnstile'),
verificationCode: t('auth.register.verificationCode'),
username: t('auth.register.username'),
usernamePlaceholder: t('auth.register.usernamePlaceholder'),
password: t('auth.register.password'),
confirmPassword: t('auth.register.confirmPassword'),
confirmPasswordPlaceholder: t('auth.register.confirmPasswordPlaceholder'),
passwordMismatch: t('auth.register.passwordMismatch'),
inviteCode: (code: string) => t('auth.register.inviteCode', { code }),
privacyPrefix: t('auth.register.privacyPrefix'),
privacyTitle: t('site.privacy.title'),
openInNewWindow: t('auth.register.openInNewWindow'),
hasAccount: t('auth.register.hasAccount'),
switchToLogin: t('auth.register.switchToLogin'),
submit: t('auth.register.submit'),
acknowledge: t('auth.register.acknowledge'),
cancel: t('common.cancel'),
}))
const inviteCodeText = computed(() => inviteCode.value ? registerUi.value.inviteCode(inviteCode.value) : '')
const privacyVersionText = computed(() => t('site.privacy.currentVersion', { version: privacyPolicyVersion.value }))
// Form nonce for password fields (prevent autofill)
const formNonce = ref(createFormNonce())
@@ -468,7 +500,7 @@ const formData = ref({
})
const isLoading = ref(false)
const loadingText = ref('注册中...')
const loadingText = ref(t('auth.register.submit'))
const isSendingCode = ref(false)
const emailVerified = ref(false)
const verificationError = ref(false)
@@ -494,7 +526,7 @@ const resetTurnstile = () => {
}
const handleTurnstileError = (message: string) => {
showError(message, '人机验证失败')
showError(message, t('auth.register.turnstile'))
}
const inviteCode = ref<string | null>(null)
@@ -504,7 +536,7 @@ const privacyPolicyEnabled = computed(() => !!props.privacyPolicy?.enabled)
const privacyPolicyVersion = computed(() => props.privacyPolicy?.version || '1')
const renderedPrivacyPolicy = computed(() => {
const policy = props.privacyPolicy
if (!policy?.content) return '<p>暂无隐私政策内容</p>'
if (!policy?.content) return `<p>${t('site.privacy.empty')}</p>`
if (policy.format === 'html') {
return sanitizeHtml(policy.content)
}
@@ -536,28 +568,28 @@ const canSendCode = computed(() => {
})
const sendCodeButtonText = computed(() => {
if (isSendingCode.value) return '发送中...'
if (emailVerified.value) return '验证成功'
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
if (isSendingCode.value) return t('auth.register.sendingCode')
if (emailVerified.value) return t('auth.register.verified')
if (cooldownSeconds.value > 0) return t('auth.register.retryAfterSeconds', { seconds: cooldownSeconds.value })
if (
turnstileRequired.value &&
currentTurnstileAction.value === 'send_verification_code' &&
!turnstileToken.value
) return '请先完成人机验证'
if (codeSentAt.value) return '重新发送验证码'
return '发送验证码'
) return t('auth.register.completeTurnstile')
if (codeSentAt.value) return t('auth.register.resendCode')
return t('auth.register.sendCode')
})
const sendCodeLoadingText = computed(() => '正在发送验证码...')
const sendCodeLoadingText = computed(() => t('auth.register.sendingCode'))
// 用户名验证
const usernameRegex = /^[a-zA-Z0-9_.-]+$/
const usernameError = computed(() => {
const username = formData.value.username.trim()
if (!username) return ''
if (username.length < 3) return '用户名长度至少为3个字符'
if (username.length > 30) return '用户名长度不能超过30个字符'
if (!usernameRegex.test(username)) return '用户名只能包含字母、数字、下划线、连字符和点号'
if (username.length < 3) return t('auth.register.usernameTooShort')
if (username.length > 30) return t('auth.register.usernameTooLong')
if (!usernameRegex.test(username)) return t('auth.register.usernameInvalid')
return ''
})
@@ -743,14 +775,14 @@ const resetForm = () => {
const handleSendCode = async () => {
if (!formData.value.email) {
showError('请输入邮箱')
showError(t('auth.register.emailRequired'))
return
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.value.email)) {
showError('请输入有效的邮箱地址', '邮箱格式错误')
showError(t('auth.register.emailInvalid'), t('auth.register.emailFormatError'))
return
}
@@ -769,7 +801,7 @@ const handleSendCode = async () => {
expireMinutes.value = response.expire_minutes
}
success(`请查收邮件,验证码有效期 ${expireMinutes.value} 分钟`, '验证码已发送')
success(t('auth.register.codeSent', { minutes: expireMinutes.value }), t('auth.register.codeSentTitle'))
// Start 60 second cooldown
startCooldown(60)
@@ -780,11 +812,11 @@ const handleSendCode = async () => {
})
} else {
resetTurnstile()
showError(response.message || '请稍后重试', '发送失败')
showError(response.message || t('auth.register.tryAgainLater'), t('auth.register.sendFailed'))
}
} catch (error: unknown) {
resetTurnstile()
showError(parseApiError(error, '网络错误,请重试'), '发送失败')
showError(parseApiError(error, t('auth.register.networkRetry')), t('auth.register.sendFailed'))
} finally {
isSendingCode.value = false
resetTurnstile()
@@ -798,7 +830,7 @@ const handleCodeComplete = async (code: string) => {
if (emailVerified.value) return
isLoading.value = true
loadingText.value = '验证中...'
loadingText.value = t('auth.register.verifying')
verificationError.value = false
try {
@@ -806,16 +838,16 @@ const handleCodeComplete = async (code: string) => {
if (response.success) {
emailVerified.value = true
success('邮箱验证通过,请继续完成注册', '验证成功')
success(t('auth.register.emailVerified'), t('auth.register.verifySuccess'))
} else {
verificationError.value = true
showError(response.message || '验证码错误', '验证失败')
showError(response.message || t('auth.register.codeInvalid'), t('auth.register.verifyFailed'))
// Clear the code input
clearCodeInputs()
}
} catch (error: unknown) {
verificationError.value = true
showError(parseApiError(error, '验证码错误,请重试'), '验证失败')
showError(parseApiError(error, t('auth.register.codeRetry')), t('auth.register.verifyFailed'))
// Clear the code input
clearCodeInputs()
} finally {
@@ -826,18 +858,18 @@ const handleCodeComplete = async (code: string) => {
const handleSubmit = async () => {
// Validate password match
if (formData.value.password !== formData.value.confirmPassword) {
showError('两次输入的密码不一致', '密码不匹配')
showError(t('auth.register.passwordMismatch'), t('auth.register.passwordError'))
return
}
if (passwordError.value) {
showError(passwordError.value, '密码错误')
showError(passwordError.value, t('auth.register.passwordError'))
return
}
// Check email verification if required
if (props.requireEmailVerification && !emailVerified.value) {
showError('请先完成邮箱验证')
showError(t('auth.register.completeEmailVerification'))
return
}
if (
@@ -845,17 +877,17 @@ const handleSubmit = async () => {
currentTurnstileAction.value === 'register' &&
!turnstileToken.value
) {
showError('请先完成人机验证')
showError(t('auth.register.completeTurnstile'))
return
}
if (privacyPolicyEnabled.value && !privacyAccepted.value) {
showError('请先阅读并同意隐私政策')
showError(t('auth.register.agreePrivacy'))
return
}
isLoading.value = true
loadingText.value = '注册中...'
loadingText.value = t('auth.register.submitting')
try {
// 构建请求数据:邮箱可选
@@ -880,13 +912,13 @@ const handleSubmit = async () => {
const response = await authApi.register(registerData)
success(response.message || '欢迎加入!请登录以继续', '注册成功')
success(response.message || t('auth.register.successMessage'), t('auth.register.successTitle'))
emit('success')
isOpen.value = false
} catch (error: unknown) {
resetTurnstile()
showError(parseApiError(error, '注册失败,请重试'), '注册失败')
showError(parseApiError(error, t('auth.register.submitRetry')), t('auth.register.submitFailed'))
} finally {
isLoading.value = false
resetTurnstile()
@@ -183,16 +183,17 @@ describe('RegisterDialog Turnstile verification flow', () => {
emailInput.dispatchEvent(new Event('input'))
await settle()
const sendButtonBeforeToken = Array.from(root.querySelectorAll('button'))
.find((button) => button.textContent?.includes('请先完成人机验证')) as HTMLButtonElement
expect(sendButtonBeforeToken.disabled).toBe(true)
const buttons = Array.from(root.querySelectorAll('button')) as HTMLButtonElement[]
const sendButtonBeforeToken = buttons.find((button) => button.type === 'button' && button.disabled && !button.dataset.testid)
expect(sendButtonBeforeToken).toBeDefined()
expect(sendButtonBeforeToken?.disabled).toBe(true)
const turnstileButton = root.querySelector('[data-testid="turnstile-widget"]') as HTMLButtonElement
turnstileButton.click()
await settle()
const sendButton = Array.from(root.querySelectorAll('button'))
.find((button) => button.textContent?.includes('发送验证码')) as HTMLButtonElement
const sendButton = buttons.find((button) => button.disabled === false && button.type === 'button' && button !== turnstileButton) as HTMLButtonElement
expect(sendButton).toBeDefined()
expect(sendButton.disabled).toBe(false)
sendButton.click()
await settle()
@@ -186,6 +186,6 @@ describe('RegisterDialog Turnstile flow', () => {
await settle()
expect(registerMock).not.toHaveBeenCalled()
expect(toastErrorMock).toHaveBeenCalledWith('人机验证加载失败,请重试', '人机验证失败')
expect(toastErrorMock).toHaveBeenCalledWith('人机验证加载失败,请重试', '人机验证')
})
})