mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
fix(frontend): complete i18n coverage and responsive layouts
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user