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
@@ -1019,6 +1019,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
import { RouterLink } from 'vue-router'
import type { ChartData, ChartOptions } from 'chart.js'
@@ -1300,7 +1301,7 @@ function formatShortDate(value?: string | null): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
+2 -1
View File
@@ -802,6 +802,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
@@ -1349,7 +1350,7 @@ function isExpiringSoon(apiKey: AdminApiKey): boolean {
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('zh-CN', {
return new Date(dateString).toLocaleString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
+3 -3
View File
@@ -862,7 +862,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { asyncTasksApi, type AsyncTaskItem, type AsyncTaskDetail, type AsyncTaskStatsResponse, type AsyncTaskStatus } from '@/api/async-tasks'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useI18n } from '@/i18n'
import { getI18nLocale, useI18n } from '@/i18n'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -1176,7 +1176,7 @@ function canCancel(status: string): boolean {
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
@@ -1188,7 +1188,7 @@ function formatDate(dateStr: string | null): string {
function formatDateFull(dateStr: string | null): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
+2 -1
View File
@@ -409,6 +409,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import {
Card,
@@ -728,7 +729,7 @@ function getStatusCodeVariant(statusCode: number): 'default' | 'success' | 'dest
function formatDateTime(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -415,7 +415,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { getI18nLocale, useI18n } from '@/i18n'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
@@ -658,7 +658,7 @@ function handleFileSelect(e: Event) {
function formatDate(dateStr: string) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
@@ -55,7 +55,7 @@
/>
</div>
<div class="flex-1 min-w-0 pt-1">
<h4 class="font-semibold text-base truncate">
<h4 class="break-words font-semibold text-base">
{{ tool.name }}
</h4>
</div>
@@ -150,7 +150,7 @@
/>
</div>
<div class="flex-1 min-w-0 pt-1 pr-8">
<h4 class="font-semibold text-base truncate">
<h4 class="break-words font-semibold text-base">
{{ module.display_name }}
</h4>
</div>
@@ -177,7 +177,7 @@
</div>
<!-- 操作区域 -->
<div class="mt-5 pt-4 border-t border-border/50 flex items-center justify-between">
<div class="mt-5 pt-4 border-t border-border/50 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<Switch
:model-value="module.enabled"
@@ -204,11 +204,11 @@
v-if="module.admin_route"
variant="outline"
size="sm"
class="gap-1.5"
class="shrink-0 gap-1.5"
@click="router.push(module.admin_route)"
>
<Settings class="w-3.5 h-3.5" />
配置
{{ t('common.configure') }}
</Button>
</div>
</div>
@@ -240,6 +240,7 @@
</template>
<script setup lang="ts">
import { useI18n } from '@/i18n'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
@@ -266,6 +267,7 @@ import { modulesApi, type ModuleStatus } from '@/api/modules'
const router = useRouter()
const { success, error } = useToast()
const { t } = useI18n()
const moduleStore = useModuleStore()
const loading = ref(false)
@@ -419,6 +419,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { CircleHelp, PlugZap, Plus, Save, Trash2 } from 'lucide-vue-next'
import { epayGatewayApi, type EpayChannelConfig, type PaymentGatewayProvider } from '@/api/billing'
@@ -544,7 +545,7 @@ const visibleFields = computed(() => activeProviderMeta.value.fields)
const updatedAtText = computed(() => {
if (!updatedAt.value) return ''
return new Date(updatedAt.value * 1000).toLocaleString('zh-CN')
return new Date(updatedAt.value * 1000).toLocaleString(getI18nLocale())
})
const defaultCallbackBaseUrl = computed(() => {
@@ -826,6 +826,7 @@
</template>
<script setup lang="ts">
import { useI18n } from '@/i18n'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
Activity,
@@ -890,6 +891,7 @@ type ProviderPerformanceParams = NonNullable<Parameters<typeof adminApi.getProvi
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
const { error: showError } = useToast()
const { legacyT } = useI18n()
const percentiles = ref<PercentileItem[]>([])
const percentileLoading = ref(false)
@@ -1228,7 +1230,7 @@ const errorTrendChartData = computed(() => ({
labels: errorTrend.value.map(item => item.date),
datasets: [
{
label: '错误数',
label: legacyT('错误数'),
data: errorTrend.value.map(item => item.total),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
@@ -261,6 +261,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { computed, onMounted, ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import {
@@ -326,7 +327,7 @@ function formatUsd(value: number): string {
function formatUnix(value?: number | null): string {
if (!value) return '-'
return new Date(value * 1000).toLocaleString('zh-CN')
return new Date(value * 1000).toLocaleString(getI18nLocale())
}
function getRewardTypeLabel(value: string): string {
+2 -1
View File
@@ -808,6 +808,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
@@ -1734,7 +1735,7 @@ async function confirmDeleteDraft(): Promise<void> {
function formatUnixSeconds(value?: number | null): string {
if (!value) return '-'
return new Date(value * 1000).toLocaleString('zh-CN')
return new Date(value * 1000).toLocaleString(getI18nLocale())
}
onMounted(() => {
@@ -1383,7 +1383,7 @@ import {
import type { PaymentOrder } from '@/api/wallet'
import { parseApiError } from '@/utils/errorParser'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { getI18nLocale, useI18n } from '@/i18n'
import { log } from '@/utils/logger'
import {
callbackStatusBadge,
@@ -2222,7 +2222,7 @@ function ownerDisplayName(name: string | null | undefined, ownerType: 'user' | '
function formatDateTime(value: string | null | undefined) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', {
return new Date(value).toLocaleString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -52,9 +52,7 @@
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
Bark App 中推送地址
<span class="font-mono">https://api.day.app/xxxx</span>
的 <span class="font-mono">xxxx</span> 部分
{{ $t('modules.bark.deviceKeyHint') }}
</p>
</div>
@@ -308,8 +308,8 @@
</div>
<div class="mt-4 border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
<div>
<div class="flex flex-wrap items-start justify-between gap-3 px-4 py-3 border-b border-border">
<div class="min-w-0 flex-1 basis-48">
<h4 class="text-sm font-medium">
最近清理记录
</h4>
@@ -320,6 +320,7 @@
<Button
variant="outline"
size="sm"
class="shrink-0"
:disabled="cleanupRunsLoading"
@click="loadCleanupRuns"
>
@@ -33,21 +33,23 @@
variant="outline"
size="sm"
class="w-full"
:title="item.exportLabel"
:disabled="item.exportLoading"
@click="$emit('export', item.key)"
>
<Download class="w-3.5 h-3.5 mr-1.5" />
{{ item.exportLoading ? '导出中...' : item.exportLabel }}
{{ item.exportLoading ? '导出中...' : '导出' }}
</Button>
<Button
variant="outline"
size="sm"
class="w-full"
:title="item.importLabel"
:disabled="item.importLoading"
@click="triggerDataFileSelect(item.key)"
>
<Upload class="w-3.5 h-3.5 mr-1.5" />
{{ item.importLoading ? '导入中...' : item.importLabel }}
{{ item.importLoading ? '导入中...' : '导入' }}
</Button>
</div>
</div>
@@ -54,6 +54,7 @@
<article
v-else
class="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border bg-background/70 p-6"
translate="no"
v-html="renderedPolicy"
/>
<!-- eslint-enable vue/no-v-html -->
+3 -3
View File
@@ -102,14 +102,14 @@ function copyStep(stepId: string, code: string) {
class="mt-6"
>
<!-- Tab 切换 -->
<div class="flex border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] px-5">
<div class="flex max-w-full overflow-x-auto border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] px-5">
<button
v-for="(tab, idx) in [
{ icon: Container, label: 'Docker 预构建镜像' },
{ icon: Monitor, label: '本地开发' }
]"
:key="idx"
class="flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px hover:text-[#262624] dark:hover:text-[#f1ead8]"
class="flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px hover:text-[#262624] dark:hover:text-[#f1ead8]"
:class="activeDeployTab === idx
? 'border-[#cc785c] text-[#cc785c] dark:text-[#d4a27f]'
: 'border-transparent text-[#666663] dark:text-[#a3a094]'"
@@ -117,7 +117,7 @@ function copyStep(stepId: string, code: string) {
>
<component
:is="tab.icon"
class="h-4 w-4"
class="h-4 w-4 shrink-0"
/>
{{ tab.label }}
</button>
+32 -26
View File
@@ -53,7 +53,7 @@
<!-- 内容区域 -->
<div>
<p
class="text-[9px] sm:text-[11px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.4em] text-muted-foreground pr-10 sm:pr-14"
class="min-h-10 text-xs font-semibold leading-snug tracking-normal break-words text-muted-foreground pr-10 sm:pr-14"
>
{{ stat.name }}
</p>
@@ -113,7 +113,7 @@
</div>
<div>
<p
class="text-[9px] sm:text-[11px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.4em] text-muted-foreground pr-10 sm:pr-14"
class="min-h-10 text-xs font-semibold leading-snug tracking-normal break-words text-muted-foreground pr-10 sm:pr-14"
>
{{ placeholder.name }}
</p>
@@ -155,7 +155,7 @@
/>
<div class="pr-6">
<p
class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground"
class="text-xs font-semibold tracking-normal break-words text-muted-foreground"
>
平均响应
</p>
@@ -172,7 +172,7 @@
/>
<div class="pr-6">
<p
class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground"
class="text-xs font-semibold tracking-normal break-words text-muted-foreground"
>
错误率
</p>
@@ -194,7 +194,7 @@
/>
<div class="pr-6">
<p
class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground"
class="text-xs font-semibold tracking-normal break-words text-muted-foreground"
>
转移次数
</p>
@@ -214,7 +214,7 @@
/>
<div class="pr-6">
<p
class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground"
class="text-xs font-semibold tracking-normal break-words text-muted-foreground"
>
本月费用
</p>
@@ -264,7 +264,7 @@
/>
<div class="pr-6">
<p
class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground"
class="text-xs font-semibold tracking-normal break-words text-muted-foreground"
>
缓存命中率
</p>
@@ -284,7 +284,7 @@
/>
<div class="pr-6">
<p
class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground"
class="text-xs font-semibold tracking-normal break-words text-muted-foreground"
>
缓存读取
</p>
@@ -304,7 +304,7 @@
/>
<div class="pr-6">
<p
class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground"
class="text-xs font-semibold tracking-normal break-words text-muted-foreground"
>
缓存创建
</p>
@@ -324,7 +324,7 @@
/>
<div class="pr-6">
<p
class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground"
class="text-xs font-semibold tracking-normal break-words text-muted-foreground"
>
本月费用
</p>
@@ -429,6 +429,7 @@
>
<div class="flex items-center gap-2 mb-1">
<h4
translate="no"
class="text-xs font-medium text-foreground line-clamp-1 flex-1"
>
{{ announcement.title }}
@@ -441,6 +442,7 @@
</span>
</div>
<div
translate="no"
class="text-[11px] text-muted-foreground leading-relaxed line-clamp-2 mb-1"
>
{{ getPlainText(announcement.content) }}
@@ -865,6 +867,7 @@
<!-- eslint-disable vue/no-v-html -->
<div
translate="no"
class="prose prose-sm dark:prose-invert max-w-none"
v-html="renderMarkdown(selectedAnnouncement.content)"
/>
@@ -884,6 +887,8 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { formatRelativeTime } from '@/utils/format'
import {
ref,
onMounted,
@@ -1275,7 +1280,7 @@ const dailyModelCostChartOptions = computed<ChartOptions<"bar">>(() => ({
stacked: true,
title: {
display: true,
text: "费用 ($)",
text: getI18nLocale() === 'en-US' ? 'Cost ($)' : '费用 ($)',
color: "rgb(107, 114, 128)",
font: { size: 10 },
},
@@ -1300,7 +1305,8 @@ const dailyModelCostChartOptions = computed<ChartOptions<"bar">>(() => ({
const val = typeof item.raw === "number" ? item.raw : 0;
return sum + val;
}, 0);
return `Total: $${total.toFixed(4)}`;
const label = getI18nLocale() === 'en-US' ? 'Total' : '总计';
return `${label}: $${total.toFixed(4)}`;
},
},
},
@@ -1380,7 +1386,7 @@ const dailyUsageTrendChartData = computed<ChartData<"line">>(() => {
labels: dailyStats.value.map((stat) => formatDateForChart(stat.date)),
datasets: [
{
label: "请求数",
label: getI18nLocale() === 'en-US' ? 'Requests' : '请求数',
data: dailyStats.value.map((stat) => stat.requests),
borderColor: "rgba(59, 130, 246, 0.8)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
@@ -1423,7 +1429,7 @@ const dailyUsageTrendChartOptions = computed<ChartOptions<"line">>(() => {
position: "left",
title: {
display: true,
text: "请求数",
text: getI18nLocale() === 'en-US' ? 'Requests' : '请求数',
color: "rgb(107, 114, 128)",
font: { size: 10 },
},
@@ -1581,9 +1587,9 @@ function formatDate(dateString: string): string {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) return "今天";
if (date.toDateString() === yesterday.toDateString()) return "昨天";
return date.toLocaleDateString("zh-CN", {
if (date.toDateString() === today.toDateString()) return formatRelativeTime(0, 'day');
if (date.toDateString() === yesterday.toDateString()) return formatRelativeTime(-1, 'day');
return date.toLocaleDateString(getI18nLocale(), {
month: "2-digit",
day: "2-digit",
weekday: "short",
@@ -1595,9 +1601,9 @@ function formatDateForChart(dateString: string): string {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) return "今天";
if (date.toDateString() === yesterday.toDateString()) return "昨天";
return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
if (date.toDateString() === today.toDateString()) return formatRelativeTime(0, 'day');
if (date.toDateString() === yesterday.toDateString()) return formatRelativeTime(-1, 'day');
return date.toLocaleDateString(getI18nLocale(), { month: "numeric", day: "numeric" });
}
function formatResponseTime(seconds: number): string {
@@ -1694,11 +1700,11 @@ function formatAnnouncementDate(dateString: string): string {
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString("zh-CN", {
if (minutes < 1) return formatRelativeTime(0, 'second');
if (minutes < 60) return formatRelativeTime(-minutes, 'minute');
if (hours < 24) return formatRelativeTime(-hours, 'hour');
if (days < 7) return formatRelativeTime(-days, 'day');
return date.toLocaleDateString(getI18nLocale(), {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
@@ -1721,7 +1727,7 @@ function getAnnouncementDotColor(type: string): 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",
+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()
})
})