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
+17 -13
View File
@@ -6,6 +6,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from '@/i18n'
import {
Chart as ChartJS,
CategoryScale,
@@ -43,6 +44,7 @@ interface Props {
}
const chartRef = ref<HTMLCanvasElement>()
const { locale } = useI18n()
let chart: ChartJS<'bar'> | null = null
const defaultOptions: ChartOptions<'bar'> = {
@@ -91,9 +93,7 @@ const defaultOptions: ChartOptions<'bar'> = {
}
}
function createChart() {
if (!chartRef.value) return
function buildChartOptions(): ChartOptions<'bar'> {
const stackedOptions = props.stacked ? {
scales: {
x: { ...defaultOptions.scales?.x, stacked: true },
@@ -106,14 +106,21 @@ function createChart() {
}
}
return {
...defaultOptions,
...stackedOptions,
locale: locale.value,
...props.options
}
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'bar',
data: props.data,
options: {
...defaultOptions,
...stackedOptions,
...props.options
}
options: buildChartOptions()
})
}
@@ -137,12 +144,9 @@ onUnmounted(() => {
})
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
watch([() => props.options, () => props.stacked, locale], () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.options = buildChartOptions()
chart.update()
}
}, { deep: true })
@@ -6,6 +6,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from '@/i18n'
import {
Chart as ChartJS,
ArcElement,
@@ -37,6 +38,7 @@ interface Props {
}
const chartRef = ref<HTMLCanvasElement>()
const { locale } = useI18n()
let chart: ChartJS<'doughnut'> | null = null
const defaultOptions: ChartOptions<'doughnut'> = {
@@ -79,6 +81,7 @@ function createChart() {
data: props.data,
options: {
...defaultOptions,
locale: locale.value,
...props.options
}
})
@@ -104,9 +107,9 @@ onUnmounted(() => {
})
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
watch([() => props.options, locale], () => {
if (chart) {
chart.options = { ...defaultOptions, ...props.options }
chart.options = { ...defaultOptions, locale: locale.value, ...props.options }
chart.update()
}
}, { deep: true })
+4 -1
View File
@@ -6,6 +6,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useI18n } from '@/i18n'
import {
Chart as ChartJS,
CategoryScale,
@@ -44,11 +45,13 @@ interface Props {
}
const chartRef = ref<HTMLCanvasElement>()
const { locale } = useI18n()
let chart: ChartJS<'line'> | null = null
function buildChartOptions(): ChartOptions<'line'> {
return {
...defaultOptions,
locale: locale.value,
...props.options
}
}
@@ -121,7 +124,7 @@ onUnmounted(() => {
// 监听引用变化,避免深监听触发整图重算
watch(() => props.data, updateChart)
watch(() => props.options, () => {
watch([() => props.options, locale], () => {
if (chart) {
chart.options = buildChartOptions()
chart.update('none')
+20 -13
View File
@@ -3,17 +3,17 @@
<canvas ref="chartRef" />
<div
v-if="crosshairStats"
class="absolute top-2 right-2 bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600"
class="absolute top-2 right-2 max-w-[calc(100%-1rem)] break-words bg-gray-800/90 text-gray-100 px-3 py-2 rounded-lg text-sm shadow-lg border border-gray-600"
>
<div class="font-medium text-yellow-400">
Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟
{{ t('chart.crosshairValue', { value: crosshairStats.yValue.toFixed(1) }) }}
</div>
<!-- 单个 dataset 时显示简单统计 -->
<div
v-if="crosshairStats.datasets.length === 1"
class="mt-1"
>
<span class="text-green-400">{{ crosshairStats.datasets[0].belowCount }}</span> / {{ crosshairStats.datasets[0].totalCount }} 点在横线以下
<span class="text-green-400">{{ crosshairStats.datasets[0].belowCount }}</span> / {{ crosshairStats.datasets[0].totalCount }} {{ t('chart.pointsBelow') }}
<span class="ml-2 text-blue-400">({{ crosshairStats.datasets[0].belowPercent.toFixed(1) }}%)</span>
</div>
<!-- 多个 dataset 时按模型分别显示 -->
@@ -36,7 +36,7 @@
</div>
<!-- 总计 -->
<div class="flex items-center gap-2 pt-1 border-t border-gray-600 mt-1">
<span class="text-gray-300">总计:</span>
<span class="text-gray-300">{{ t('chart.total') }}:</span>
<span class="text-green-400">{{ crosshairStats.totalBelowCount }}</span>/<span class="text-gray-400">{{ crosshairStats.totalCount }}</span>
<span class="text-blue-400">({{ crosshairStats.totalBelowPercent.toFixed(1) }}%)</span>
</div>
@@ -46,6 +46,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale, useI18n } from '@/i18n'
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import {
Chart as ChartJS,
@@ -62,6 +63,7 @@ import {
type Scale
} from 'chart.js'
import 'chartjs-adapter-date-fns'
import { enUS, zhCN } from 'date-fns/locale'
const props = withDefaults(defineProps<Props>(), {
height: 300,
@@ -114,6 +116,7 @@ interface GapInfo {
}
const chartRef = ref<HTMLCanvasElement>()
const { locale, t } = useI18n()
let chart: ChartJS<'scatter'> | null = null
const crosshairY = ref<number | null>(null)
@@ -151,7 +154,7 @@ const crosshairStats = computed<CrosshairStats | null>(() => {
if (dsTotal > 0) {
datasetStats.push({
label: dataset.label || 'Unknown',
label: dataset.label || t('chart.unknown'),
color: (dataset.backgroundColor as string) || 'rgba(59, 130, 246, 0.7)',
belowCount,
totalCount: dsTotal,
@@ -325,7 +328,8 @@ function formatDuration(ms: number): string {
return `${minutes}m`
}
const defaultOptions: ChartOptions<'scatter'> = {
const defaultOptions = computed<ChartOptions<'scatter'>>(() => ({
locale: locale.value,
responsive: true,
maintainAspectRatio: false,
interaction: {
@@ -335,6 +339,9 @@ const defaultOptions: ChartOptions<'scatter'> = {
scales: {
x: {
type: 'time',
adapters: {
date: { locale: locale.value === 'zh-CN' ? zhCN : enUS }
},
time: {
displayFormats: {
hour: 'HH:mm'
@@ -378,7 +385,7 @@ const defaultOptions: ChartOptions<'scatter'> = {
},
title: {
display: true,
text: '间隔 (分钟)',
text: t('chart.intervalAxis'),
color: 'rgb(107, 114, 128)'
},
afterBuildTicks(scale: Scale) {
@@ -407,7 +414,7 @@ const defaultOptions: ChartOptions<'scatter'> = {
const point = contexts[0].raw as { x: string; _originalX?: string }
const timeStr = point._originalX || point.x
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
@@ -417,7 +424,7 @@ const defaultOptions: ChartOptions<'scatter'> = {
label: (context) => {
const point = context.raw as { x: string; y: number; _originalY?: number }
const realY = point._originalY ?? toRealValue(point.y)
return `间隔: ${realY.toFixed(1)} 分钟`
return t('chart.intervalTooltip', { value: realY.toFixed(1) })
}
}
}
@@ -451,7 +458,7 @@ const defaultOptions: ChartOptions<'scatter'> = {
chartInstance.draw()
}
}
}))
// 修改 crosshairPlugin 使用显示值
const crosshairPluginWithTransform: Plugin<'scatter'> = {
@@ -544,7 +551,7 @@ function createChart() {
type: 'scatter',
data: chartData,
options: {
...defaultOptions,
...defaultOptions.value,
...props.options
},
plugins: [crosshairPluginWithTransform, gapMarkerPlugin]
@@ -586,10 +593,10 @@ watch(
],
updateChart
)
watch(() => props.options, () => {
watch([() => props.options, defaultOptions], () => {
if (chart) {
chart.options = {
...defaultOptions,
...defaultOptions.value,
...props.options
}
chart.update('none')
@@ -0,0 +1,114 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, type App } from 'vue'
import type { ChartConfiguration, ChartData, ChartOptions } from 'chart.js'
import BarChart from '../BarChart.vue'
import ScatterChart from '../ScatterChart.vue'
import CostForecastChart from '@/components/stats/CostForecastChart.vue'
import { setI18nLocale } from '@/i18n'
const { chartConstructor } = vi.hoisted(() => ({ chartConstructor: vi.fn() }))
vi.mock('chartjs-adapter-date-fns', () => ({}))
vi.mock('chart.js', async importOriginal => {
const original = await importOriginal<typeof import('chart.js')>()
return {
...original,
Chart: class {
static register = vi.fn()
data: ChartData
options: ChartOptions
update = vi.fn()
destroy = vi.fn()
constructor(canvas: HTMLCanvasElement, config: ChartConfiguration) {
this.data = config.data
this.options = config.options ?? {}
chartConstructor(canvas, config, this)
}
},
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
async function mountChart(app: App) {
const root = document.createElement('div')
document.body.appendChild(root)
app.mount(root)
mountedApps.push({ app, root })
await nextTick()
await nextTick()
}
function renderedChart() {
return chartConstructor.mock.calls[chartConstructor.mock.calls.length - 1]?.[2] as {
data: ChartData
options: ChartOptions<'scatter'>
update: ReturnType<typeof vi.fn>
}
}
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
chartConstructor.mockClear()
})
describe('Chart locale updates', () => {
it('redraws the scatter axis when the locale changes without changing its data', async () => {
await mountChart(createApp({
render: () => h(ScatterChart, {
data: { datasets: [{ label: 'model-a', data: [{ x: 1000, y: 2 }] }] },
}),
}))
const chart = renderedChart()
const initialData = chart.data
expect(chart.options.scales?.y?.title?.text).toBe('间隔 (分钟)')
setI18nLocale('en-US')
await nextTick()
expect(chart.options.locale).toBe('en-US')
expect(chart.options.scales?.y?.title?.text).toBe('Interval (minutes)')
expect(chart.data).toBe(initialData)
expect(chart.update).toHaveBeenCalledWith('none')
})
it('updates forecast legend labels while preserving cost values', async () => {
await mountChart(createApp({
render: () => h(CostForecastChart, {
title: 'Forecast',
history: [{ date: '2026-09-01', total_cost: 12.5 }],
forecast: [{ date: '2026-09-02', total_cost: 13 }],
}),
}))
const chart = renderedChart()
expect(chart.data.datasets.map(dataset => dataset.label)).toEqual(['实际成本', '预测成本'])
setI18nLocale('en-US')
await nextTick()
expect(chart.data.datasets.map(dataset => dataset.label)).toEqual(['Actual cost', 'Forecast cost'])
expect(chart.data.datasets.map(dataset => dataset.data)).toEqual([[12.5, null], [null, 13]])
})
it('preserves unstacked bars when the locale changes', async () => {
await mountChart(createApp({
render: () => h(BarChart, {
stacked: false,
data: { labels: ['model-a'], datasets: [{ data: [2] }] },
}),
}))
setI18nLocale('en-US')
await nextTick()
const chart = renderedChart()
expect(chart.options.locale).toBe('en-US')
expect(chart.options.scales?.x?.stacked).toBe(false)
expect(chart.options.scales?.y?.stacked).toBe(false)
})
})
@@ -2,7 +2,7 @@
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
:aria-label="t('common.language')"
:title="t('common.language')"
type="button"
@@ -18,12 +18,13 @@
v-for="option in options"
:key="option.value"
class="justify-between gap-3"
:lang="option.value"
@select="setLocale(option.value)"
>
<span>{{ option.label }}</span>
<Check
v-if="locale === option.value"
class="h-4 w-4 text-primary"
class="h-4 w-4 shrink-0 text-primary"
/>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -1,10 +1,10 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<div class="flex max-w-full flex-wrap items-center gap-2">
<Select
v-model="selectedPreset"
>
<SelectTrigger
class="h-8 w-32 text-xs border-border/60"
class="h-8 w-40 text-xs border-border/60"
:class="[presetTriggerClass]"
>
<SelectValue :placeholder="legacyT('选择时间段')" />
@@ -22,7 +22,7 @@
<div
v-if="selectedPreset === 'custom'"
class="flex items-center gap-2"
class="flex max-w-full flex-wrap items-center gap-2"
>
<Input
v-model="startDate"
@@ -5,8 +5,8 @@
:class="headerClasses"
>
<slot name="header">
<div class="flex items-center justify-between">
<div>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0 flex-1 basis-64">
<h3
v-if="title"
class="text-lg font-medium leading-6 text-foreground"
@@ -20,7 +20,10 @@
{{ description }}
</p>
</div>
<div v-if="$slots.actions">
<div
v-if="$slots.actions"
class="max-w-full shrink-0 [&>div]:flex-wrap [&_button]:shrink-0"
>
<slot name="actions" />
</div>
</div>
@@ -1,11 +1,11 @@
<template>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-3">
<slot name="icon">
<div
v-if="icon"
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10"
>
<component
:is="icon"
@@ -14,13 +14,13 @@
</div>
</slot>
<div>
<h1 class="text-2xl font-semibold text-foreground sm:text-3xl">
<div class="min-w-0">
<h1 class="break-words text-2xl font-semibold text-foreground sm:text-3xl">
{{ title }}
</h1>
<p
v-if="description"
class="mt-1 text-sm text-muted-foreground"
class="mt-1 break-words text-sm text-muted-foreground"
>
{{ description }}
</p>
@@ -30,7 +30,7 @@
<div
v-if="$slots.actions"
class="flex items-center gap-2"
class="flex min-w-0 flex-wrap items-center gap-2 sm:max-w-[50%] sm:justify-end"
>
<slot name="actions" />
</div>
@@ -21,7 +21,7 @@
:class="index > 0 ? 'pt-1' : ''"
>
<span class="text-[10px] font-medium text-muted-foreground/50 font-mono tabular-nums">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.1em]">{{ group.title }}</span>
<span class="min-w-0 break-words text-[10px] font-semibold leading-4 text-muted-foreground/70 uppercase tracking-normal">{{ group.title }}</span>
</div>
<!-- Links -->
@@ -37,7 +37,7 @@
<TooltipTrigger as-child>
<RouterLink
:to="item.href"
class="group relative flex items-center rounded-lg"
class="group relative flex min-w-0 items-center gap-2 rounded-lg"
:class="[
collapsed
? 'h-9 justify-center px-0 transition-colors duration-150'
@@ -62,7 +62,7 @@
/>
<span
v-if="!collapsed"
class="truncate text-[13px] tracking-tight"
class="min-w-0 break-words text-[13px] leading-5 tracking-normal"
>{{ item.name }}</span>
</div>
@@ -4,26 +4,27 @@
<Teleport to="body">
<div
v-if="tooltip.visible && tooltip.day"
class="fixed z-50 rounded-lg border border-border/70 bg-background px-3 py-2 text-xs shadow-lg backdrop-blur pointer-events-none"
ref="tooltipRef"
class="fixed z-50 w-[200px] max-w-[calc(100vw-1rem)] break-words rounded-lg border border-border/70 bg-background px-3 py-2 text-xs shadow-lg backdrop-blur pointer-events-none"
:style="tooltipStyle"
>
<p class="font-medium">
{{ tooltip.day.date }}
{{ formatDay(tooltip.day.date) }}
</p>
<p class="mt-0.5">
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
{{ t('heatmap.requests', { count: tooltip.day.requests }) }} · {{ formatTokens(tooltip.day.total_tokens) }}
</p>
<p class="text-[11px] text-muted-foreground">
成本 {{ formatCurrency(tooltip.day.total_cost) }}
{{ t('heatmap.cost', { value: formatCurrency(tooltip.day.total_cost) }) }}
</p>
</div>
</Teleport>
<div
v-if="showHeader"
class="flex items-center justify-between gap-4"
class="flex flex-wrap items-center justify-between gap-4"
>
<div class="flex-shrink-0">
<div class="min-w-0 break-words">
<p class="text-sm font-semibold">
{{ title }}
</p>
@@ -38,20 +39,20 @@
v-if="weekColumns.length > 0"
class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0"
>
<span class="flex-shrink-0"></span>
<span class="flex-shrink-0">{{ t('heatmap.less') }}</span>
<div
v-for="(level, index) in legendLevels"
:key="index"
class="w-3 h-3 rounded-[3px] flex-shrink-0"
:style="getLegendStyle(level)"
/>
<span class="flex-shrink-0"></span>
<span class="flex-shrink-0">{{ t('heatmap.more') }}</span>
</div>
</div>
<div
v-if="weekColumns.length > 0"
class="flex w-full gap-3"
class="flex w-full gap-3 overflow-x-auto"
>
<div
class="flex flex-col text-[10px] text-muted-foreground flex-shrink-0"
@@ -62,33 +63,12 @@
M
</div>
<span
:style="dayLabelStyle"
class="flex items-center invisible"
>周日</span>
<span
v-for="(weekday, index) in weekdayLabels"
:key="index"
:style="dayLabelStyle"
class="flex items-center"
></span>
<span
:style="dayLabelStyle"
class="flex items-center invisible"
>周二</span>
<span
:style="dayLabelStyle"
class="flex items-center"
></span>
<span
:style="dayLabelStyle"
class="flex items-center invisible"
>周四</span>
<span
:style="dayLabelStyle"
class="flex items-center"
></span>
<span
:style="dayLabelStyle"
class="flex items-center invisible"
>周六</span>
:class="{ invisible: index % 2 === 0 }"
>{{ weekday }}</span>
</div>
<div class="flex-1 min-w-[200px]">
<div
@@ -103,7 +83,7 @@
v-for="(week, weekIndex) in weekColumns"
:key="`month-${weekIndex}`"
:style="monthCellStyle"
class="text-center"
class="whitespace-nowrap text-left"
>
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
</div>
@@ -146,15 +126,16 @@
v-else
class="text-xs text-muted-foreground"
>
暂无活跃数据
{{ t('heatmap.empty') }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ActivityHeatmap, ActivityHeatmapDay } from '@/types/activity'
import { formatCurrency, formatTokens } from '@/utils/format'
import { useI18n } from '@/i18n'
const props = withDefaults(defineProps<{
data?: ActivityHeatmap | null
@@ -169,12 +150,23 @@ const props = withDefaults(defineProps<{
})
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
const { locale, t } = useI18n()
const weekdayLabels = computed(() => {
const formatter = new Intl.DateTimeFormat(locale.value, { weekday: 'short', timeZone: 'UTC' })
return Array.from({ length: 7 }, (_, day) => formatter.format(new Date(Date.UTC(2024, 0, 7 + day))))
})
function formatDay(value: string): string {
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeZone: 'UTC' })
.format(new Date(`${value}T00:00:00Z`))
}
type DayWithMeta = ActivityHeatmapDay & { dateObj: Date }
const heatmapWrapper = ref<HTMLElement | null>(null)
const heatmapWidth = ref(0)
const cellSize = ref(10)
const cellGap = ref(4)
const tooltipRef = ref<HTMLElement | null>(null)
const tooltip = ref<{ day: ActivityHeatmapDay | null; x: number; y: number; visible: boolean; below: boolean }>({
day: null,
x: 0,
@@ -259,6 +251,7 @@ const weekColumns = computed(() => {
const monthMarkers = computed(() => {
const markers: Record<number, string> = {}
const columns = weekColumns.value
const formatter = new Intl.DateTimeFormat(locale.value, { month: 'short', timeZone: 'UTC' })
let lastMonth: number | null = null
columns.forEach((week, index) => {
@@ -270,7 +263,7 @@ const monthMarkers = computed(() => {
if (month === lastMonth) {
return
}
markers[index] = `${month + 1}`
markers[index] = formatter.format(firstValid.dateObj)
lastMonth = month
})
@@ -343,10 +336,14 @@ onBeforeUnmount(() => {
}
})
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
async function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const tooltipWidth = 200
const tooltipHeight = 72
tooltip.value = { day, x: cellRect.left, y: cellRect.top, visible: true, below: false }
await nextTick()
if (!tooltip.value.visible || tooltip.value.day?.date !== day.date) return
const tooltipWidth = tooltipRef.value?.offsetWidth || 200
const tooltipHeight = tooltipRef.value?.offsetHeight || 72
// Calculate horizontal position (centered on cell)
let left = cellRect.left + cellRect.width / 2
@@ -401,11 +398,11 @@ function getCellStyle(requests: number) {
}
function buildTooltip(day: ActivityHeatmapDay): string {
const dateLabel = day.date
const dateLabel = formatDay(day.date)
const costLabel = formatCurrency(day.total_cost || 0)
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
const parts = [dateLabel, t('heatmap.requests', { count: day.requests }), `${formatTokens(day.total_tokens)} tokens`, costLabel]
if (day.actual_total_cost !== undefined) {
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
parts.push(t('heatmap.actualCost', { value: formatCurrency(day.actual_total_cost) }))
}
return parts.join(' · ')
}
@@ -29,6 +29,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/i18n'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
@@ -45,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
})
const { t } = useI18n()
const labels = computed(() => [
...props.history.map(item => item.date),
@@ -58,7 +60,7 @@ const chartData = computed(() => {
labels: labels.value,
datasets: [
{
label: '实际成本',
label: t('chart.actualCost'),
data: historyValues.concat(new Array(forecastValues.length).fill(null)),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.15)',
@@ -66,7 +68,7 @@ const chartData = computed(() => {
pointRadius: 2
},
{
label: '预测成本',
label: t('chart.forecastCost'),
data: new Array(historyValues.length).fill(null).concat(forecastValues),
borderColor: 'rgb(234, 179, 8)',
backgroundColor: 'rgba(234, 179, 8, 0.15)',
@@ -59,6 +59,7 @@
<script setup lang="ts">
import { Card } from '@/components/ui'
import { getI18nLocale } from '@/i18n'
import { EmptyState, LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
import type { QuotaUsageProvider } from '@/api/admin'
@@ -76,6 +77,6 @@ withDefaults(defineProps<Props>(), {
})
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
return new Date(value).toLocaleDateString(getI18nLocale())
}
</script>
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref, type App } from 'vue'
import Pagination from '../pagination.vue'
import { setI18nLocale } from '@/i18n'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('Pagination', () => {
it('updates the summary and accessible page controls when the locale changes', async () => {
const root = document.createElement('div')
document.body.appendChild(root)
const updateCurrent = vi.fn()
const app = createApp({
render: () => h(Pagination, {
current: 1,
total: 1250,
pageSize: 20,
showPageSizeSelector: false,
'onUpdate:current': updateCurrent,
}),
})
app.mount(root)
mountedApps.push({ app, root })
expect(root.querySelector('[aria-live]')?.textContent).toContain('共 1,250 条')
expect(root.querySelector('[aria-current="page"]')?.getAttribute('aria-label')).toBe('第 1 页')
setI18nLocale('en-US')
await nextTick()
expect(root.querySelector('[aria-live]')?.textContent).toContain('Showing 1-20 of 1,250 items')
expect(root.querySelector('[aria-current="page"]')?.getAttribute('aria-label')).toBe('Page 1')
expect(root.querySelector('input')?.getAttribute('aria-label')).toBe('Go to page')
root.querySelector<HTMLButtonElement>('[aria-label="Page 2"]')?.click()
expect(updateCurrent).toHaveBeenCalledWith(2)
})
it('shows a zero-based empty range after the final record is removed', async () => {
const total = ref(1)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
render: () => h(Pagination, {
current: 1,
total: total.value,
showPageSizeSelector: false,
}),
})
app.mount(root)
mountedApps.push({ app, root })
total.value = 0
setI18nLocale('en-US')
await nextTick()
expect(root.querySelector('[aria-live]')?.textContent).toContain('Showing 0-0 of 0 items')
expect(root.querySelectorAll('button')).toHaveLength(0)
})
})
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, type App } from 'vue'
import Tabs from '../tabs.vue'
import TabsList from '../tabs-list.vue'
import TabsTrigger from '../tabs-trigger.vue'
import { setI18nLocale, useI18n } from '@/i18n'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('TabsList', () => {
it('repositions the indicator after translated labels change width', async () => {
vi.useFakeTimers()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return { width: this.textContent === '个人设置' ? 80 : 140 } as DOMRect
})
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(4)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp({
setup() {
const { t } = useI18n()
return () => h(Tabs, { modelValue: 'settings' }, {
default: () => h(TabsList, {}, {
default: () => h(TabsTrigger, { value: 'settings' }, () => t('common.settings')),
}),
})
},
})
app.mount(root)
mountedApps.push({ app, root })
await nextTick()
await vi.runAllTimersAsync()
const indicator = root.querySelector<HTMLElement>('.tabs-indicator')
expect(indicator?.style.width).toBe('80px')
expect(indicator?.style.transform).toBe('translateX(4px)')
setI18nLocale('en-US')
await nextTick()
await vi.runAllTimersAsync()
expect(indicator?.style.width).toBe('140px')
})
})
+2 -2
View File
@@ -31,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
const buttonClass = computed(() => {
const baseClass =
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
'inline-flex min-w-0 max-w-full items-center justify-center rounded-xl text-sm font-semibold leading-5 [overflow-wrap:anywhere] [&_svg]:shrink-0 transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
const variantClasses = {
default: 'bg-primary text-white hover:bg-primary/90',
@@ -48,7 +48,7 @@ const buttonClass = computed(() => {
default: 'h-11 px-5',
sm: 'h-9 rounded-lg px-3',
lg: 'h-12 rounded-xl px-8 text-base',
icon: 'h-11 w-11 rounded-2xl',
icon: 'h-11 w-11 shrink-0 rounded-2xl',
}
return cn(
+4 -4
View File
@@ -57,12 +57,12 @@
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-balance text-base font-semibold leading-tight text-foreground sm:text-lg">
<h3 class="break-words text-balance text-base font-semibold leading-tight text-foreground sm:text-lg">
{{ title }}
</h3>
<p
v-if="description"
class="mt-0.5 text-pretty text-xs leading-4 text-muted-foreground"
class="mt-0.5 break-words text-pretty text-xs leading-4 text-muted-foreground"
>
{{ description }}
</p>
@@ -80,7 +80,7 @@
<!-- Footer 区域如果有 footer 插槽自动添加样式 -->
<div
v-if="slots.footer"
class="flex shrink-0 flex-col-reverse items-stretch gap-2 border-t border-border bg-background/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur-sm [&>button]:w-full sm:flex-row-reverse sm:items-center sm:gap-3 sm:bg-muted/10 sm:px-6 sm:py-4 sm:[&>button]:w-auto"
class="flex shrink-0 flex-col-reverse items-stretch gap-2 border-t border-border bg-background/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur-sm [&>button]:min-h-min [&>button]:w-full [&>button]:whitespace-normal [&>button]:py-2 sm:flex-row-reverse sm:flex-wrap sm:items-center sm:gap-3 sm:bg-muted/10 sm:px-6 sm:py-4 sm:[&>button]:w-auto"
>
<slot name="footer" />
</div>
@@ -169,7 +169,7 @@ const maxWidthClass = computed(() => {
})
const contentBodyClass = computed(() => [
'min-h-0 overflow-y-auto overscroll-contain',
'min-h-0 min-w-0 overflow-y-auto overscroll-contain',
props.noPadding ? '' : 'px-4 py-3 sm:px-6',
].filter(Boolean).join(' '))
@@ -1,5 +1,5 @@
<template>
<div class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3">
<div class="flex flex-col-reverse gap-3 border-t border-border bg-muted/10 px-4 py-4 [&>button]:min-h-min [&>button]:whitespace-normal [&>button]:py-2 sm:flex-row-reverse sm:flex-wrap sm:px-6">
<slot />
</div>
</template>
</template>
@@ -27,7 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
const contentClass = computed(() =>
cn(
'z-[200] min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card p-1 text-foreground shadow-2xl backdrop-blur-xl',
'z-[200] min-w-[8rem] max-w-[calc(100vw-1rem)] max-h-[var(--radix-dropdown-menu-content-available-height)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-card p-1 text-foreground shadow-2xl backdrop-blur-xl',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
@@ -16,7 +16,7 @@ defineEmits<{
const itemClass = computed(() =>
cn(
'relative flex cursor-pointer select-none items-center rounded-lg px-3 py-1.5 text-sm outline-none',
'relative flex min-w-0 cursor-pointer select-none items-center whitespace-normal break-words rounded-lg px-3 py-1.5 text-sm leading-5 outline-none [&_svg]:shrink-0',
'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
+1 -1
View File
@@ -16,7 +16,7 @@ const props = defineProps<Props>()
const labelClass = computed(() =>
cn(
'text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
'text-[11px] font-semibold tracking-normal break-words text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class
)
)
+31 -21
View File
@@ -1,8 +1,11 @@
<template>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 border-t border-border/60 px-4 sm:px-6 py-3 sm:py-4 bg-muted/20">
<div class="flex min-w-0 flex-col gap-3 border-t border-border/60 bg-muted/20 px-4 py-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-4 sm:px-6 sm:py-4">
<!-- 左侧记录范围和每页数量 -->
<div class="flex items-center justify-between sm:justify-start gap-3 text-sm text-muted-foreground">
<span class="font-medium whitespace-nowrap">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground sm:justify-start">
<span
class="min-w-0 break-words font-medium tabular-nums"
aria-live="polite"
>
{{ rangeSummary }}
</span>
<Select
@@ -10,7 +13,10 @@
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger class="w-[120px] h-8 sm:h-9 border-border/60 text-xs sm:text-sm">
<SelectTrigger
class="h-8 w-auto min-w-[120px] shrink-0 border-border/60 text-xs sm:h-9 sm:text-sm"
:aria-label="t('pagination.pageSizeLabel')"
>
<span class="flex-1 text-center">
<SelectValue />
</span>
@@ -31,8 +37,8 @@
<div class="flex flex-wrap items-center justify-center gap-1.5 sm:gap-2 sm:ml-auto">
<!-- 页码按钮智能省略 -->
<template
v-for="page in pageNumbers"
:key="page"
v-for="(page, index) in pageNumbers"
:key="`${page}-${index}`"
>
<Button
v-if="typeof page === 'number'"
@@ -40,9 +46,11 @@
size="sm"
class="h-9 min-w-[36px] px-2"
:class="page === current ? 'shadow-sm' : ''"
:aria-label="t('pagination.pageNumber', { page: formatNumber(page) })"
:aria-current="page === current ? 'page' : undefined"
@click="handlePageChange(page)"
>
{{ page }}
{{ formatNumber(page) }}
</Button>
<span
v-else
@@ -55,18 +63,19 @@
v-if="totalPages > 7"
class="flex items-center gap-1.5 ml-2 text-sm text-muted-foreground"
>
<span class="hidden sm:inline">{{ jumpToLabel }}</span>
<span class="hidden sm:inline">{{ t('pagination.goToPage') }}</span>
<input
v-model="jumpPageInput"
type="text"
inputmode="numeric"
pattern="[0-9]*"
:aria-label="t('pagination.jumpToPage')"
class="w-12 h-9 px-2 text-center text-sm border border-border/60 rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60"
@keydown.enter="handleJumpPage"
@blur="handleJumpPage"
@input="filterNumericInput"
>
<span class="hidden sm:inline">{{ pageLabel }}</span>
<span class="hidden sm:inline">{{ t('pagination.pageLabel') }}</span>
</div>
</div>
</div>
@@ -102,28 +111,29 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
const jumpPageInput = ref('')
const locale = useI18n().locale
const { locale, t } = useI18n()
const numberFormatter = computed(() => new Intl.NumberFormat(locale.value))
function formatNumber(value: number): string {
return numberFormatter.value.format(value)
}
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
const recordRange = computed(() => {
const start = (props.current - 1) * props.pageSize + 1
const start = props.total === 0 ? 0 : (props.current - 1) * props.pageSize + 1
const end = Math.min(props.current * props.pageSize, props.total)
return { start, end }
})
const rangeSummary = computed(() => {
if (locale.value === 'en-US') {
return `Showing ${recordRange.value.start}-${recordRange.value.end} of ${props.total} items`
}
return `显示 ${recordRange.value.start}-${recordRange.value.end} 条,共 ${props.total}`
})
const jumpToLabel = computed(() => locale.value === 'en-US' ? 'Go to' : '跳至')
const pageLabel = computed(() => locale.value === 'en-US' ? 'page' : '页')
const rangeSummary = computed(() => t('pagination.range', {
start: formatNumber(recordRange.value.start),
end: formatNumber(recordRange.value.end),
total: formatNumber(props.total),
}))
function pageSizeLabel(size: number): string {
return locale.value === 'en-US' ? `${size} / page` : `${size} 条/页`
return t('pagination.pageSize', { size: formatNumber(size) })
}
const pageNumbers = computed(() => {
@@ -21,7 +21,8 @@
<Input
ref="searchInputRef"
v-model="searchQuery"
:placeholder="searchPlaceholder"
:placeholder="searchPlaceholder ?? t('common.searchPlaceholder')"
:aria-label="searchPlaceholder ?? t('common.searchPlaceholder')"
class="h-9 rounded-xl border-border/60 bg-background/80 pl-9 pr-3 text-sm"
@keydown.stop
/>
@@ -34,7 +35,7 @@
v-if="showEmptyState"
class="px-3 py-2 text-sm text-muted-foreground"
>
未找到匹配项
{{ t('common.noSearchResults') }}
</div>
</SelectViewport>
</SelectContentPrimitive>
@@ -66,6 +67,7 @@ import {
type RegisteredSelectItem,
} from './select-search-context'
import { matchesSearchQuery, preloadPinyin } from '@/utils/search'
import { useI18n } from '@/i18n'
interface Props {
class?: string
@@ -90,9 +92,10 @@ const props = withDefaults(defineProps<Props>(), {
disablePortal: undefined,
searchable: true,
searchThreshold: 8,
searchPlaceholder: '输入关键词搜索...',
searchPlaceholder: undefined,
})
const { t } = useI18n()
const isInsideDialog = inject(DIALOG_CONTEXT_KEY, false)
const shouldDisablePortal = computed(
() => props.disablePortal ?? isInsideDialog,
@@ -177,7 +180,7 @@ watch(showSearchInput, async (visible) => {
const contentClass = computed(() =>
cn(
'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'z-[200] max-h-96 min-w-[var(--radix-select-trigger-width,8rem)] max-w-[min(calc(100vw-1rem),var(--radix-select-content-available-width,100vw))] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class,
+1 -1
View File
@@ -101,7 +101,7 @@ onBeforeUnmount(() => {
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<SelectItemText class="min-w-0 whitespace-normal break-words text-left">
<slot />
</SelectItemText>
</SelectItemPrimitive>
@@ -13,7 +13,7 @@ const props = defineProps<Props>()
const triggerClass = computed(() =>
cn(
'flex h-11 w-full items-center justify-between rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 text-foreground cursor-pointer backdrop-blur transition-all',
'flex h-11 w-full min-w-0 items-center justify-between gap-2 rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-left text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 text-foreground cursor-pointer backdrop-blur transition-all',
props.class
)
)
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
:class="triggerClass"
:disabled="disabled"
>
<span class="truncate">
<span class="min-w-0 flex-1 truncate">
<slot />
</span>
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useSlots
import { ArrowDown, ArrowUp, ArrowUpDown, ListFilter } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { useI18n } from '@/i18n'
import TableHead from './table-head.vue'
type SortDirection = 'asc' | 'desc'
@@ -29,7 +30,7 @@ const props = withDefaults(defineProps<{
align: 'left',
title: undefined,
filterActive: false,
filterTitle: '筛选',
filterTitle: undefined,
filterContentClass: undefined,
})
@@ -42,6 +43,7 @@ defineOptions({
})
const attrs = useAttrs()
const { t } = useI18n()
const slots = useSlots()
const rootRef = ref<HTMLElement | null>(null)
const filterTriggerRef = ref<HTMLButtonElement | null>(null)
@@ -65,12 +67,12 @@ const ariaSort = computed(() => {
return props.direction === 'asc' ? 'ascending' : 'descending'
})
const wrapperClass = computed(() => cn(
'relative flex w-full items-center gap-1.5',
'relative flex min-w-0 w-full items-center gap-1.5',
props.align === 'center' && 'justify-center',
props.align === 'right' && 'justify-end',
))
const labelClass = computed(() => cn(
'inline-flex min-w-0 items-center gap-1.5 text-xs font-semibold text-muted-foreground',
'inline-flex min-w-0 items-center gap-1.5 whitespace-normal break-words text-left text-xs font-semibold leading-4 text-muted-foreground',
props.align === 'center' && 'justify-center',
props.align === 'right' && 'justify-end',
))
@@ -89,7 +91,7 @@ const filterButtonClass = computed(() => cn(
: 'text-muted-foreground/60 hover:bg-muted/50 hover:text-foreground',
))
const filterPanelClass = computed(() => cn(
'fixed z-[1000] w-64 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none',
'fixed z-[1000] w-64 max-w-[calc(100vw-1rem)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none',
props.filterContentClass,
))
@@ -185,7 +187,8 @@ onBeforeUnmount(() => {
ref="filterTriggerRef"
type="button"
:class="filterButtonClass"
:title="filterTitle"
:title="filterTitle ?? t('common.filter')"
:aria-label="filterTitle ?? t('common.filter')"
:aria-pressed="filterActive"
@click.stop="toggleFilter"
>
@@ -210,7 +213,7 @@ onBeforeUnmount(() => {
v-if="canSort"
type="button"
:class="buttonClass"
:title="title || '排序'"
:title="title || t('common.sort')"
@click="handleSort"
>
<slot />
+2 -2
View File
@@ -4,7 +4,7 @@
role="switch"
:aria-checked="modelValue"
:disabled="disabled"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50"
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="[
modelValue ? 'bg-primary' : 'bg-muted'
]"
@@ -28,4 +28,4 @@ defineProps<{
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
</script>
+14 -8
View File
@@ -15,12 +15,14 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
import { useI18n } from '@/i18n'
interface Props {
class?: string
}
const props = defineProps<Props>()
const { locale } = useI18n()
const listRef = ref<HTMLElement | null>(null)
const indicatorStyle = ref<Record<string, string>>({
@@ -82,11 +84,7 @@ const updateIndicator = () => {
// 确保按钮已渲染
if (buttonRect.width === 0) return
// 计算相对位置:累加前面所有按钮的宽度
let offsetLeft = 0
for (let i = 0; i < newIndex; i++) {
offsetLeft += buttons[i].getBoundingClientRect().width
}
const offsetLeft = activeButton.offsetLeft
// 判断是否需要动画:
// 1. 首次初始化不需要动画
@@ -123,7 +121,7 @@ const scheduleIndicatorUpdate = () => {
// 监听 activeTab 变化
watch(
() => activeTab?.value,
() => [activeTab?.value, locale.value],
() => {
nextTick(() => {
scheduleIndicatorUpdate()
@@ -166,9 +164,11 @@ onUnmounted(() => {
<style scoped>
.tabs-list {
position: relative;
height: 2.5rem;
min-height: 2.5rem;
max-width: 100%;
overflow-x: auto;
align-items: center;
justify-content: center;
justify-content: flex-start;
border-radius: 0.5rem;
background-color: hsl(var(--muted) / 0.3);
padding: 0.25rem;
@@ -176,6 +176,12 @@ onUnmounted(() => {
border: 1px solid hsl(var(--border) / 0.6);
}
.tabs-list.grid :deep(button[data-value]) {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.tabs-indicator {
position: absolute;
z-index: 0;
+1 -1
View File
@@ -32,7 +32,7 @@ const handleClick = () => {
const triggerClass = computed(() => {
return cn(
'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'relative z-10 inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium [&_svg]:shrink-0 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
isActive.value
? 'text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground',
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setI18nLocale } from '@/i18n'
import { useToast } from '../useToast'
import { useConfirm } from '../useConfirm'
afterEach(() => {
useToast().clearAll()
useConfirm().handleCancel()
})
describe('localized feedback', () => {
it('retranslates an existing toast in both directions without changing its identity', () => {
const { showToast, toasts, removeToast } = useToast()
setI18nLocale('en-US')
const id = showToast({ title: '保存', description: '保存成功', duration: 0 })
expect(toasts.value[0]).toMatchObject({ id, title: 'Save' })
setI18nLocale('zh-CN')
expect(toasts.value[0]).toMatchObject({ id, title: '保存', message: '保存成功' })
setI18nLocale('en-US')
expect(toasts.value[0].title).toBe('Save')
removeToast(id)
expect(toasts.value).toEqual([])
})
it('keeps the pending confirmation while its labels change language', async () => {
const { confirm, state, handleConfirm } = useConfirm()
setI18nLocale('en-US')
const result = confirm({ message: '保存', confirmText: '保存' })
expect(state.value.confirmText).toBe('Save')
setI18nLocale('zh-CN')
expect(state.value).toMatchObject({ isOpen: true, message: '保存', confirmText: '保存' })
setI18nLocale('en-US')
expect(state.value.message).toBe('Save')
handleConfirm()
expect(await result).toBe(true)
expect(state.value.isOpen).toBe(false)
})
})
+12 -6
View File
@@ -1,4 +1,4 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { getI18nLocale } from '@/i18n'
import { translateLegacyText } from '@/i18n/messages'
@@ -40,10 +40,10 @@ export function useConfirm() {
return new Promise((resolve) => {
state.value = {
isOpen: true,
title: localizeConfirmText(options.title || '确认操作'),
message: localizeConfirmText(options.message),
confirmText: localizeConfirmText(options.confirmText || '确认'),
cancelText: localizeConfirmText(options.cancelText || '取消'),
title: options.title || '确认操作',
message: options.message,
confirmText: options.confirmText || '确认',
cancelText: options.cancelText || '取消',
variant: options.variant || 'question',
resolve
}
@@ -107,7 +107,13 @@ export function useConfirm() {
}
return {
state,
state: computed(() => ({
...state.value,
title: localizeConfirmText(state.value.title || '确认操作'),
message: localizeConfirmText(state.value.message),
confirmText: localizeConfirmText(state.value.confirmText || '确认'),
cancelText: localizeConfirmText(state.value.cancelText || '取消'),
})),
confirm,
confirmDanger,
confirmWarning,
+8 -4
View File
@@ -1,4 +1,4 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { TOAST_CONFIG } from '@/config/constants'
import { getI18nLocale } from '@/i18n'
import { translateLegacyText } from '@/i18n/messages'
@@ -36,8 +36,8 @@ export function useToast() {
duration: 5000,
...toastOptions,
variant: normalizeToastVariant(options.variant),
title: localizeToastText(options.title),
message: localizeToastText(options.message ?? description),
title: options.title,
message: options.message ?? description,
}
@@ -81,7 +81,11 @@ export function useToast() {
}
return {
toasts,
toasts: computed(() => toasts.value.map(toast => ({
...toast,
title: localizeToastText(toast.title),
message: localizeToastText(toast.message),
}))),
showToast,
removeToast,
toast: showToast,
@@ -533,6 +533,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, watch, computed } from 'vue'
import {
X,
@@ -760,7 +761,7 @@ function handleClose() {
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
return date.toLocaleDateString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit'
@@ -940,7 +940,7 @@ import {
} from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useI18n } from '@/i18n'
import { getI18nLocale, useI18n } from '@/i18n'
import Button from '@/components/ui/button.vue'
import Card from '@/components/ui/card.vue'
import { useToast } from '@/composables/useToast'
@@ -2476,7 +2476,7 @@ function isKiroBannedKey(key: EndpointAPIKey): boolean {
function formatBanTimestamp(timestamp: number | undefined): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
month: 'short',
day: 'numeric',
hour: '2-digit',
@@ -26,7 +26,7 @@
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>{{ legacyT('提供商类型') }}</Label>
<Select
@@ -130,7 +130,7 @@
</h3>
<!-- 超时配置 -->
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
{{ legacyT('流式首字节超时') }}
@@ -164,11 +164,11 @@
</div>
<!-- 提供商内转移限制 -->
<div class="grid grid-cols-2 gap-2 sm:gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="min-w-0 space-y-1.5">
<Label
for="max-transfer-count"
class="whitespace-nowrap text-xs sm:text-sm"
class="text-xs sm:text-sm"
>
{{ legacyT('最大转移次数') }}
</Label>
@@ -185,7 +185,7 @@
<div class="min-w-0 space-y-1.5">
<Label
for="max-transfer-timeout-seconds"
class="whitespace-nowrap text-xs sm:text-sm"
class="text-xs sm:text-sm"
>
{{ legacyT('最大转移超时') }}
<span class="text-xs text-muted-foreground">{{ legacyT('(秒)') }}</span>
@@ -1,3 +1,5 @@
import { getI18nLocale } from '@/i18n'
export type HealthBadgeVariant =
| 'default'
| 'secondary'
@@ -127,7 +129,7 @@ export function formatMs(value?: number | null) {
}
function formatDurationNumber(value: number) {
return new Intl.NumberFormat('zh-CN', {
return new Intl.NumberFormat(getI18nLocale(), {
maximumFractionDigits: Math.abs(value) < 10 ? 2 : 1
}).format(value)
}
@@ -144,7 +146,7 @@ export function formatAvailability(item: HealthMonitorAvailability) {
export function formatTps(value?: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '-'
return `${new Intl.NumberFormat('zh-CN', {
return `${new Intl.NumberFormat(getI18nLocale(), {
maximumFractionDigits: value < 10 ? 2 : value < 100 ? 1 : 0
}).format(value)} tps`
}
@@ -202,7 +204,7 @@ function formatTimelineRequestBreakdown(
function formatTimelineCount(value?: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) return '-'
return `${new Intl.NumberFormat('zh-CN').format(value)}`
return `${new Intl.NumberFormat(getI18nLocale()).format(value)}`
}
function formatTimelineMetricAvailability(metrics?: HealthTimelineTooltipMetrics | null) {
@@ -213,7 +215,7 @@ function formatTimelineMetricAvailability(metrics?: HealthTimelineTooltipMetrics
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat('zh-CN', {
return new Intl.NumberFormat(getI18nLocale(), {
notation: 'compact',
maximumFractionDigits: 1
}).format(value)
@@ -223,7 +225,7 @@ export function formatTimestamp(timestamp?: string | null) {
if (!timestamp) return '未知时间'
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return '未知时间'
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
@@ -544,6 +544,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, watch, computed, onBeforeUnmount } from 'vue'
import { isAxiosError } from 'axios'
import Card from '@/components/ui/card.vue'
@@ -2294,7 +2295,7 @@ const resolveAttemptTimeRange = (attempt: CandidateRecord | null | undefined): A
// 格式化时间(详细)
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const timeStr = date.toLocaleTimeString('zh-CN', {
const timeStr = date.toLocaleTimeString(getI18nLocale(), {
hour12: false,
hour: '2-digit',
minute: '2-digit',
@@ -873,6 +873,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import Button from '@/components/ui/button.vue'
import { useEscapeKey } from '@/composables/useEscapeKey'
@@ -2848,7 +2849,7 @@ onBeforeUnmount(() => {
function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return 'N/A'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
return date.toLocaleString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -509,6 +509,7 @@
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { computed, ref, watch } from 'vue'
import {
Badge,
@@ -1027,7 +1028,7 @@ async function submitCompleteRefund() {
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',
+38 -2
View File
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
import { createI18n, useI18n, useLocaleOptions } from '@/i18n'
import { createI18n, getI18nLocale, normalizeLocale, setI18nLocale, useI18n, useLocaleOptions } from '@/i18n'
import { formatDate, formatRelativeTime } from '@/utils/format'
import { translateLegacyText } from '@/i18n/messages'
import { transformLegacyTemplateI18n } from '@/i18n/legacy-template-transform'
@@ -118,4 +119,39 @@ describe('i18n infrastructure', () => {
expect(translateLegacyText(' 发布于 2026-01-01 ', 'en-US')).toBe(' Published at 2026-01-01 ')
expect(translateLegacyText('git clone https://github.com/fawney19/Aether.git', 'en-US')).toBe('git clone https://github.com/fawney19/Aether.git')
})
it('normalizes saved language aliases without accepting unrelated language names', () => {
expect(normalizeLocale('en')).toBe('en-US')
expect(normalizeLocale('en_GB')).toBe('en-US')
expect(normalizeLocale(' ZH-cn ')).toBe('zh-CN')
expect(normalizeLocale('english')).toBeUndefined()
expect(normalizeLocale('fr-FR')).toBeUndefined()
})
it('continues switching language when browser storage is unavailable', () => {
const write = vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new DOMException('Storage blocked', 'SecurityError')
})
try {
expect(() => setI18nLocale('en-US')).not.toThrow()
expect(getI18nLocale()).toBe('en-US')
expect(document.documentElement.lang).toBe('en-US')
} finally {
write.mockRestore()
}
})
it('updates date and relative-time formatting with the selected language', () => {
const date = '2026-09-07T12:30:00'
setI18nLocale('zh-CN')
const chineseDate = formatDate(date)
expect(formatRelativeTime(-1, 'day')).toBe('昨天')
setI18nLocale('en-US')
expect(formatRelativeTime(-1, 'day')).toBe('yesterday')
expect(formatRelativeTime(-1, 'minute')).toBe('1 minute ago')
expect(formatRelativeTime(-2, 'minute')).toBe('2 minutes ago')
expect(formatDate(date)).not.toBe(chineseDate)
setI18nLocale('zh-CN')
expect(formatDate(date)).toBe(chineseDate)
})
})
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as Vue from 'vue'
import { compile, nextTick, ref } from 'vue'
import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc'
import { installLegacyDomTranslator } from '@/i18n/dom-translator'
import { transformLegacyTemplateI18n, transformVueSource } from '@/i18n/legacy-template-transform'
import { translateLegacyText, type Locale } from '@/i18n/messages'
describe('legacy template compiler', () => {
it('translates content following nested slot templates and accepts reordered setup attributes', () => {
const source = `<template><Panel><template #default>保存</template></Panel><footer>取消</footer></template>
<script lang="ts" setup>const name = 'value'</script>`
const result = transformVueSource(source)
const { descriptor, errors } = parse(result.code)
expect(errors).toEqual([])
expect(descriptor.template?.content).toContain('{{ __aetherLegacyT("取消") }}')
expect(result.code.match(/<script/g)).toHaveLength(1)
expect(() => compileScript(descriptor, { id: 'nested-template' })).not.toThrow()
expect(transformVueSource(result.code).changed).toBe(false)
})
it('preserves comparisons and user values while translating displayed branches', () => {
const source = `<span>{{ count < limit ? '保存' : user.name }}</span>
<span>{{ status === '保存' }}</span><span>{{ names['保存'] }}</span>
<span>{{ format('保存') }}</span><span>{{ legacyT('保存') }}</span>
<span :title="count > limit ? '关闭' : user.name">{{ user.name }}</span>`
const result = transformLegacyTemplateI18n(source)
expect(result.code).toContain(`count < limit ? __aetherLegacyT("保存") : user.name`)
expect(result.code).toContain(`{{ status === '保存' }}`)
expect(result.code).toContain(`{{ names['保存'] }}`)
expect(result.code).toContain(`{{ format('保存') }}`)
expect(result.code).toContain(`{{ legacyT('保存') }}`)
expect(result.code).toContain(`{{ user.name }}`)
expect(compileTemplate({ source: result.code, id: 'comparisons', filename: 'comparisons.vue' }).errors).toEqual([])
})
it('matches an existing plain script language when adding the setup helper', () => {
const result = transformVueSource('<template><span>保存</span></template><script>export default { name: "Legacy" }</script>')
const { descriptor } = parse(result.code)
expect(descriptor.scriptSetup?.lang).toBe(descriptor.script?.lang)
expect(() => compileScript(descriptor, { id: 'plain-script' })).not.toThrow()
})
it('keeps quotes and HTML entities intact in static and bound attributes', () => {
const result = transformLegacyTemplateI18n(`<input title="保存 &quot;O&apos;Reilly&quot; &amp; &lt;x&gt;" placeholder=保存 :aria-label="open ? '关闭 &amp; 保存' : '保存'">`)
const render = compile(result.code) as (context: Record<string, unknown>, cache: unknown[]) => Vue.VNode
const vnode = render({ open: true, __aetherLegacyT: (value: string) => value }, [])
expect(vnode.props?.title).toBe(`保存 "O'Reilly" & <x>`)
expect(vnode.props?.placeholder).toBe('保存')
expect(vnode.props?.['aria-label']).toBe('关闭 & 保存')
})
it('translates template literal text without translating interpolated values', () => {
const result = transformLegacyTemplateI18n('<span>{{ `保存 ${user.name}` }}</span>')
expect(result.code).toContain('`${__aetherLegacyT("保存 ")}${user.name}`')
const render = compile(result.code) as (context: Record<string, unknown>, cache: unknown[]) => Vue.VNode
const vnode = render({ user: { name: '取消' }, __aetherLegacyT: (value: string) => value === '保存 ' ? 'Save ' : 'unexpected' }, [])
expect(vnode.children).toBe('Save 取消')
})
it.each(['HelpHint', 'help-hint'])('translates static and displayed literal text props on %s', tag => {
const context = {
expanded: true,
record: { text: '保存' },
__aetherLegacyT: (value: string) => translateLegacyText(value, 'en-US'),
}
const staticResult = transformLegacyTemplateI18n(`<${tag} text="保存" />`)
const renderStatic = compile(staticResult.code, { isCustomElement: name => name === tag }) as (context: Record<string, unknown>, cache: unknown[]) => Vue.VNode
expect(renderStatic(context, []).props?.text).toBe('Save')
const dynamicResult = transformLegacyTemplateI18n(`<${tag} :text="expanded ? '关闭' : record.text" />`)
const renderDynamic = compile(dynamicResult.code, { isCustomElement: name => name === tag }) as (context: Record<string, unknown>, cache: unknown[]) => Vue.VNode
expect(renderDynamic(context, []).props?.text).toBe('Close')
expect(renderDynamic({ ...context, expanded: false }, []).props?.text).toBe('保存')
})
it('keeps ordinary text props and opted-out HelpHint text as application data', () => {
const source = `<Message text="保存" /><Message :text="active ? '关闭' : record.text" />
<div text="保存" /><HelpHint translate="no" text="保存" />
<HelpHint data-i18n-skip :text="active ? '关闭' : record.text" />`
expect(transformLegacyTemplateI18n(source).code).toBe(source)
})
it('respects skipped subtrees including v-pre and contenteditable', () => {
const source = `<section translate="no"><span title="关闭">保存</span></section>
<section data-i18n-skip><span>保存</span></section>
<section v-pre title="保存 > 标题"><span>{{ '保存' }}</span></section>
<section contenteditable="true">保存</section><pre>保存</pre><code>保存</code>`
const result = transformLegacyTemplateI18n(source)
expect(result.code).toBe(source.replace('<section v-pre', '<section data-i18n-skip v-pre'))
expect(result.needsHelper).toBe(false)
expect(transformLegacyTemplateI18n(result.code).changed).toBe(false)
expect(transformLegacyTemplateI18n('<span title="v-pre">保存</span>').changed).toBe(true)
})
})
describe('legacy DOM translation updates', () => {
let stop: () => void
let locale: Vue.Ref<Locale>
let root: HTMLDivElement
async function flushTranslation(): Promise<void> {
await nextTick()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(40)
}
beforeEach(() => {
vi.useFakeTimers()
root = document.createElement('div')
document.body.append(root)
locale = ref<Locale>('en-US')
stop = installLegacyDomTranslator(locale)
})
afterEach(() => {
stop()
root.remove()
vi.useRealTimers()
})
it('translates new state on a reused text node and restores the latest source', async () => {
const text = document.createTextNode('保存')
root.append(text)
await flushTranslation()
expect(text.nodeValue).toBe('Save')
text.nodeValue = '保存中...'
await flushTranslation()
expect(text.nodeValue).toBe('Saving...')
locale.value = 'zh-CN'
await flushTranslation()
expect(text.nodeValue).toBe('保存中...')
text.nodeValue = '取消'
locale.value = 'en-US'
await flushTranslation()
expect(text.nodeValue).toBe('Cancel')
locale.value = 'zh-CN'
await flushTranslation()
expect(text.nodeValue).toBe('取消')
})
it('updates attributes across changes, removal, and language round trips', async () => {
root.title = '关闭'
await flushTranslation()
expect(root.title).toBe('Close')
root.title = '保存'
await flushTranslation()
expect(root.title).toBe('Save')
locale.value = 'zh-CN'
await flushTranslation()
expect(root.title).toBe('保存')
root.removeAttribute('title')
await flushTranslation()
root.title = '取消'
locale.value = 'en-US'
await flushTranslation()
expect(root.title).toBe('Cancel')
root.title = 'User supplied English'
locale.value = 'zh-CN'
await flushTranslation()
expect(root.title).toBe('User supplied English')
})
it('preserves code, editable values, and explicit untranslated subtrees', async () => {
root.innerHTML = `<code title="关闭">保存</code><pre>保存</pre><textarea>保存</textarea>
<span contenteditable="true">保存</span><span v-pre>保存</span>
<section translate="no"><span title="关闭">保存</span></section>
<section data-i18n-skip><span title="关闭">保存</span></section>`
const original = root.innerHTML
await flushTranslation()
expect(root.innerHTML).toBe(original)
locale.value = 'zh-CN'
await flushTranslation()
expect(root.innerHTML).toBe(original)
})
it('restores original text when a translated region becomes opted out', async () => {
root.textContent = '保存'
root.title = '关闭'
await flushTranslation()
expect(root.textContent).toBe('Save')
root.setAttribute('translate', 'no')
await flushTranslation()
expect(root.textContent).toBe('保存')
expect(root.title).toBe('关闭')
})
})
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { legacyUiEnglishMessages } from '@/i18n/legacy-ui-messages'
import { legacyAdminEnglishMessages } from '@/i18n/legacy-admin-messages'
import { legacyGuideEnglishMessages } from '@/i18n/legacy-guide-messages'
import { messages, translateLegacyText } from '@/i18n/messages'
const placeholders = (message: string) => [...message.matchAll(/\{(\w+)\}/g)]
.map(match => match[1])
.sort()
describe('translation coverage', () => {
it('keeps locale keys and interpolation parameters aligned', () => {
expect(Object.keys(messages['en-US']).sort()).toEqual(Object.keys(messages['zh-CN']).sort())
for (const key of Object.keys(messages['zh-CN']) as Array<keyof typeof messages['zh-CN']>) {
expect(placeholders(messages['en-US'][key]), key).toEqual(placeholders(messages['zh-CN'][key]))
}
})
it('provides complete English sentences for the extended UI catalog', () => {
const catalog = { ...legacyUiEnglishMessages, ...legacyAdminEnglishMessages, ...legacyGuideEnglishMessages }
for (const [source, translation] of Object.entries(catalog)) {
expect(translation, source).not.toMatch(/[\u4e00-\u9fff]/u)
expect(translateLegacyText(source, 'en-US'), source).not.toMatch(/[\u4e00-\u9fff]/u)
expect(translateLegacyText(source, 'zh-CN'), source).toBe(source)
}
})
it.each([
['暂无登录设备记录', 'No sign-in devices recorded'],
['请妥善保管,此令牌只会显示一次', 'Store this token securely. It is shown only once.'],
['给密钥起一个有意义的名称方便识别', 'Use a descriptive name to identify this key'],
['套餐不足时继续扣钱包余额', 'Charge the wallet when plan credit runs out'],
['冲突处理模式', 'Conflict handling'],
['如果发现任何冲突,导入将在写入前预检并中止', 'Conflicts are checked before any data is written. The import stops if a conflict is found.'],
['有未保存的更改,确定要关闭吗?', 'You have unsaved changes. Close without saving?'],
['该独立 Key 的钱包尚未初始化,暂时无法进行资金操作', 'This key wallet has not been initialized. Balance operations are unavailable.'],
['1月', 'Jan'],
['12月', 'Dec'],
['用户已启用', 'User enabled'],
['用户已禁用', 'User disabled'],
['用户已删除', 'User deleted'],
['成功率 99.2%', 'Success rate 99.2%'],
['输入 1.8M / 输出 0.7M', 'Input 1.8M / Output 0.7M'],
['节省 $12.34 (21%)', 'Saved $12.34 (21%)'],
['总用户 156', 'Total users 156'],
['IP restrictions: 不限制', 'IP restrictions: No restriction'],
['2 维度', '2 dimensions'],
['1 维度', '1 dimension'],
['14天0时', '14d 0h'],
['5天 0:00:00', '5d 0:00:00'],
])('translates %s as a complete message', (source, expected) => {
expect(translateLegacyText(source, 'en-US')).toBe(expected)
})
it('reuses keyed translations for static legacy UI', () => {
expect(translateLegacyText('打开导航菜单', 'en-US')).toBe(messages['en-US']['common.openMenu'])
expect(translateLegacyText('再次输入密码', 'en-US')).toBe(messages['en-US']['auth.register.confirmPasswordPlaceholder'])
})
it.each([
'研发用户的专属密钥',
'用户已保存的自定义内容',
'华东数据分析项目',
'上海服务提供商',
'客户张月',
'用户14天0时',
'5天 50:90:00',
'30月',
' Keep this 用户输入 exactly as written.\n',
'{"name":"生产环境密钥","enabled":true}',
])('preserves unknown content: %s', (source) => {
expect(translateLegacyText(source, 'en-US')).toBe(source)
})
it('preserves dynamic names, formatting, and outer whitespace', () => {
expect(translateLegacyText('\n 该独立 Key 的钱包尚未初始化,暂时无法进行资金操作 \n', 'en-US'))
.toBe('\n This key wallet has not been initialized. Balance operations are unavailable. \n')
expect(translateLegacyText('选择目标客户端和模型 ID。点击导入后浏览器会请求打开 CC Switch,本页面不会展示或保存包含 API\n Key 的链接。', 'en-US'))
.toBe('Select a client and model ID. Import opens CC Switch through your browser. Links containing your API key are not displayed or stored on this page.')
expect(translateLegacyText(' 已删除映射 华东用户模型 ', 'en-US'))
.toBe(' Deleted mapping 华东用户模型 ')
expect(translateLegacyText('确认删除(生产账号)', 'en-US'))
.toBe('Confirm delete (生产账号)')
expect(translateLegacyText('12 分钟', 'en-US')).toBe('12 min')
expect(translateLegacyText('发布于 2026-09-07', 'en-US')).toBe('Published at 2026-09-07')
})
})
+67 -78
View File
@@ -5,17 +5,21 @@ import { translateLegacyText, type Locale } from './messages'
const cjkPattern = /[\u4e00-\u9fff]/
const skippedTags = new Set(['SCRIPT', 'STYLE', 'CODE', 'PRE', 'KBD', 'SAMP', 'TEXTAREA'])
const translatableAttributes = ['alt', 'aria-label', 'placeholder', 'title']
const skipAttributes = ['translate', 'data-i18n-skip', 'contenteditable', 'v-pre']
const originalText = new WeakMap<Text, string>()
const originalAttributes = new WeakMap<Element, Map<string, string>>()
interface TranslationState {
source: string
rendered: string
}
let observer: MutationObserver | null = null
let scheduled = false
const originalText = new WeakMap<Text, TranslationState>()
const originalAttributes = new WeakMap<Element, Map<string, TranslationState>>()
let stopActiveTranslator: (() => void) | null = null
function shouldSkipElement(element: Element | null): boolean {
let current: Element | null = element
let current = element
while (current) {
if (skippedTags.has(current.tagName) || current.hasAttribute('contenteditable')) {
if (skippedTags.has(current.tagName) || current.hasAttribute('contenteditable') || current.hasAttribute('v-pre') || current.hasAttribute('data-i18n-skip') || current.getAttribute('translate')?.toLowerCase() === 'no') {
return true
}
current = current.parentElement
@@ -23,73 +27,56 @@ function shouldSkipElement(element: Element | null): boolean {
return false
}
function translateTextNode(node: Text, locale: Locale): void {
if (shouldSkipElement(node.parentElement)) return
if (locale !== 'en-US') {
const original = originalText.get(node)
if (original !== undefined && node.nodeValue !== original) {
node.nodeValue = original
}
return
}
const current = node.nodeValue ?? ''
const source = originalText.get(node) ?? current
if (!cjkPattern.test(source)) return
const translated = translateLegacyText(source, locale)
if (!originalText.has(node)) {
originalText.set(node, source)
}
if (translated !== current) {
node.nodeValue = translated
function translateValue(current: string, previous: TranslationState | undefined, locale: Locale): TranslationState {
// A renderer may reuse the same node for a new status or record. Only reuse
// the cached source while the DOM still contains our most recent output.
const source = previous && current === previous.rendered ? previous.source : current
return {
source,
rendered: cjkPattern.test(source) ? translateLegacyText(source, locale) : source,
}
}
function translateElementAttributes(element: Element, locale: Locale): void {
if (shouldSkipElement(element)) return
function translateTextNode(node: Text, locale: Locale): void {
const current = node.nodeValue ?? ''
const previous = originalText.get(node)
if (shouldSkipElement(node.parentElement)) {
if (previous && current === previous.rendered) node.nodeValue = previous.source
originalText.delete(node)
return
}
const state = translateValue(current, previous, locale)
originalText.set(node, state)
if (state.rendered !== current) node.nodeValue = state.rendered
}
function translateElementAttributes(element: Element, locale: Locale): void {
let originals = originalAttributes.get(element)
const skipped = shouldSkipElement(element)
for (const attribute of translatableAttributes) {
const current = element.getAttribute(attribute)
if (current === null) continue
if (locale !== 'en-US') {
const original = originals?.get(attribute)
if (original !== undefined && current !== original) {
element.setAttribute(attribute, original)
}
const previous = originals?.get(attribute)
if (current === null || skipped) {
if (skipped && previous && current === previous.rendered) element.setAttribute(attribute, previous.source)
originals?.delete(attribute)
continue
}
const source = originals?.get(attribute) ?? current
if (!cjkPattern.test(source)) continue
const translated = translateLegacyText(source, locale)
const state = translateValue(current, previous, locale)
if (!originals) {
originals = new Map()
originalAttributes.set(element, originals)
}
if (!originals.has(attribute)) {
originals.set(attribute, source)
}
if (translated !== current) {
element.setAttribute(attribute, translated)
}
originals.set(attribute, state)
if (state.rendered !== current) element.setAttribute(attribute, state.rendered)
}
}
function translateDom(root: ParentNode, locale: Locale): void {
if (root instanceof Element) {
translateElementAttributes(root, locale)
}
const elements = root.querySelectorAll?.('*') ?? []
for (const element of elements) {
translateElementAttributes(element, locale)
}
if (root instanceof Element) translateElementAttributes(root, locale)
for (const element of root.querySelectorAll('*')) translateElementAttributes(element, locale)
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
@@ -99,36 +86,38 @@ function translateDom(root: ParentNode, locale: Locale): void {
}
}
function scheduleDomTranslation(locale: Ref<Locale>): void {
if (scheduled) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
if (document.body) {
translateDom(document.body, locale.value)
}
})
}
export function installLegacyDomTranslator(locale: Ref<Locale>): () => void {
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
if (stopActiveTranslator) return stopActiveTranslator
export function installLegacyDomTranslator(locale: Ref<Locale>): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return
if (observer) return
void nextTick(() => scheduleDomTranslation(locale))
watch(locale, () => {
void nextTick(() => scheduleDomTranslation(locale))
})
observer = new MutationObserver(() => {
scheduleDomTranslation(locale)
})
let frame: number | null = null
let stopped = false
const schedule = (): void => {
if (stopped || frame !== null) return
frame = requestAnimationFrame(() => {
frame = null
if (!stopped && document.body) translateDom(document.body, locale.value)
})
}
void nextTick(schedule)
const stopWatching = watch(locale, () => { void nextTick(schedule) })
const observer = new MutationObserver(schedule)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: translatableAttributes,
attributeFilter: [...translatableAttributes, ...skipAttributes],
characterData: true,
childList: true,
subtree: true,
})
stopActiveTranslator = () => {
if (stopped) return
stopped = true
observer.disconnect()
stopWatching()
if (frame !== null) cancelAnimationFrame(frame)
stopActiveTranslator = null
}
return stopActiveTranslator
}
+19 -9
View File
@@ -30,8 +30,12 @@ function isLocale(value: string | null | undefined): value is Locale {
function readInitialLocale(): Locale {
if (typeof window === 'undefined') return defaultLocale
const stored = localStorage.getItem(STORAGE_KEY)
if (isLocale(stored)) return stored
try {
const stored = normalizeLocale(localStorage.getItem(STORAGE_KEY))
if (stored) return stored
} catch {
// Browser storage may be unavailable; the in-memory preference still works.
}
const preferred = navigator.languages?.find(language => {
const normalized = normalizeLocale(language)
@@ -41,21 +45,26 @@ function readInitialLocale(): Locale {
return isLocale(normalizedPreferred) ? normalizedPreferred : defaultLocale
}
function normalizeLocale(value: string | undefined): string | undefined {
export function normalizeLocale(value: string | null | undefined): Locale | undefined {
if (!value) return undefined
const lower = value.toLowerCase()
if (lower.startsWith('zh')) return 'zh-CN'
if (lower.startsWith('en')) return 'en-US'
return value
const language = value.trim().toLowerCase().split(/[-_]/)[0]
if (language === 'zh') return 'zh-CN'
if (language === 'en') return 'en-US'
return undefined
}
function setLocale(nextLocale: Locale): void {
if (!isLocale(nextLocale)) return
locale.value = nextLocale
if (typeof document !== 'undefined') {
document.documentElement.lang = nextLocale
}
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, nextLocale)
try {
localStorage.setItem(STORAGE_KEY, nextLocale)
} catch {
// Persistence is optional; do not interrupt language switching.
}
}
}
@@ -88,7 +97,8 @@ export function createI18n() {
app.config.globalProperties.$t = t
app.config.globalProperties.$legacyT = legacyT
setLocale(locale.value)
installLegacyDomTranslator(locale)
const stopTranslating = installLegacyDomTranslator(locale)
app.onUnmount(stopTranslating)
}
}
}
+681
View File
@@ -0,0 +1,681 @@
export const legacyAdminEnglishMessages: Record<string, string> = {
'配置额度、流量限制、会员权益和组合套餐': 'Configure credit, usage limits, membership benefits, and bundled plans',
'新建套餐': 'Create plan',
'套餐列表': 'Plans',
'启用后的套餐会出现在用户套餐中心': 'Enabled plans appear in the user plan catalog',
'暂无套餐': 'No plans',
'创建第一个套餐后,用户可以在套餐中心购买': 'Create a plan to make it available for users to purchase',
'权益有效期': 'Benefit validity',
'权益': 'Benefits',
'删除套餐': 'Delete plan',
'编辑套餐': 'Edit plan',
'通过固定权益控件生成套餐配置': 'Configure a plan using the benefit settings',
'每日额度月卡': 'Monthly plan with daily credit',
'周期内每天重置': 'Resets daily during the plan period',
'流量限制套餐': 'Usage limit plan',
'QPS、RPM 与并发组合': 'Combined QPS, RPM, and concurrency limits',
'动态授予分组': 'Grant group membership dynamically',
'周期权益组合': 'Bundled benefits for a fixed period',
'套餐名称': 'Plan name',
'套餐名称说明': 'About plan names',
'用户端购买页和订单快照里显示的套餐名称。': 'The plan name displayed on the purchase page and in order snapshots.',
'Pro 月卡': 'Pro monthly plan',
'价格字段说明': 'About plan pricing',
'设置用户实际支付的套餐价格。': 'Set the price users pay for this plan.',
'套餐说明字段说明': 'About plan descriptions',
'简短描述套餐包含的权益,建议控制在一两句话内。': 'Describe the included benefits in one or two sentences.',
'简短说明套餐权益': 'Briefly describe the plan benefits',
'购买限制': 'Purchase limits',
'重复购买限制': 'Repeat purchase limit',
'重复购买限制说明': 'About repeat purchase limits',
'控制同一用户能否重复购买本套餐;不决定余额、每日额度或会员分组怎么发放。': 'Controls whether a user can purchase this plan again. It does not determine how wallet credit, daily credit, or membership groups are granted.',
'按周期限制': 'Limit per period',
'永久限制': 'Lifetime limit',
'不限购': 'No purchase limit',
'周期窗口说明': 'About the purchase period',
'日': 'Day',
'自定义天数': 'Custom number of days',
'最多持有份数字段说明': 'About the maximum number of active plans',
'当前逻辑:': 'Current behavior:',
'每日额度和会员权益仍按类型互斥;使用限制套餐默认可同时生效,相同指标与窗口按最严格上限执行。仅同名套餐互斥组会替换旧套餐;替换时组合权益整体失效。': 'Daily credit and membership benefits remain mutually exclusive within their respective types. Usage limit plans can coexist, with the strictest limit applied to matching metrics and windows. Plans only replace existing plans in the same exclusivity group; replacement expires all benefits in the previous bundle.',
'展示与上架': 'Display and availability',
'展示排序': 'Display order',
'展示排序说明': 'About display order',
'数值越小越靠前,用户端套餐列表按排序值升序展示。': 'Lower values appear first. The user plan catalog is sorted in ascending order.',
'上架状态': 'Availability',
'上架状态说明': 'About availability',
'停用后保留配置,但用户端套餐中心不再展示。': 'Disabling a plan keeps its configuration but removes it from the user plan catalog.',
'已上架': 'Available',
'未上架': 'Unavailable',
'权益配置': 'Benefit settings',
'发放金额 (USD)': 'Credit amount (USD)',
'余额类型': 'Balance type',
'套餐互斥组(可选)': 'Plan exclusivity group (optional)',
'与新套餐的任一权益使用同名组时,旧套餐及其组合权益会整体失效。': 'If any benefit in the new plan uses the same group name, the previous plan and all its bundled benefits expire.',
'每日额度 (USD)': 'Daily credit (USD)',
'重置时区': 'Reset time zone',
'每日额度套餐本身已按类型互斥;填写后还可与其他权益类型的同名组整包互斥。': 'Daily credit plans are already mutually exclusive. Set a group to also replace entire plans with other benefit types in the same group.',
'允许超额扣钱包': 'Allow wallet charges after credit is exhausted',
'额度不足时继续使用钱包余额': 'Use the wallet balance when daily credit is insufficient',
'额度结转': 'Credit rollover',
'当前后端固定不支持结转': 'Credit rollover is currently unsupported by the server',
'会员分组': 'Membership group',
'选择要授予的用户分组': 'Select the user group to grant',
'手动输入分组 ID': 'Enter a group ID manually',
'会员权益包本身已按类型互斥;填写后还可与其他权益类型的同名组整包互斥。': 'Membership plans are already mutually exclusive. Set a group to also replace entire plans with other benefit types in the same group.',
'使用限制': 'Usage limits',
'消费额度、QPS、RPM、周期请求数和并发硬限制': 'Hard limits on spending, QPS, RPM, requests per period, and concurrency',
'删除策略': 'Delete policy',
'策略名称': 'Policy name',
'标准流量限制': 'Standard usage limits',
'策略标识': 'Policy identifier',
'同组套餐换购时旧套餐整包失效;留空时此策略可与其他策略同时生效,相同指标与窗口按较低上限执行。每日额度和会员权益仍按类型互斥。': 'Purchasing another plan in the same group expires the entire previous plan. Leave empty to allow this policy to coexist with others; the lower limit applies to matching metrics and windows. Daily credit and membership benefits remain mutually exclusive within their respective types.',
'消费金额 (USD)': 'Spending (USD)',
'滚动窗口': 'Rolling window',
'自然日': 'Calendar day',
'自然周': 'Calendar week',
'自然月': 'Calendar month',
'套餐周期': 'Plan period',
'同时进行': 'Concurrent',
'继承系统时区': 'Use system time zone',
'上限 (USD)': 'Limit (USD)',
'上限': 'Limit',
'删除规则': 'Delete rule',
'滚动 5 小时请求数': 'Requests in a rolling 5-hour window',
'5 小时消费额度': '5-hour spending limit',
'每日消费额度': 'Daily spending limit',
'每周消费额度': 'Weekly spending limit',
'每月消费额度': 'Monthly spending limit',
'套餐周期消费额度': 'Spending limit per plan period',
'每日请求数': 'Daily requests',
'每周请求数': 'Weekly requests',
'每月请求数': 'Monthly requests',
'套餐周期请求数': 'Requests per plan period',
'每个策略最多': 'Maximum per policy:',
'条规则': 'rules',
'每个套餐的规则合计最多': 'Maximum total rules per plan:',
'添加策略': 'Add policy',
'每个套餐最多': 'Maximum per plan:',
'份策略': 'policies',
'保存套餐': 'Save plan',
'新建策略': 'Create policy',
'拖动调整顺序': 'Drag to reorder',
'正在加载调度策略': 'Loading routing policies',
'暂无调度策略,可以先创建一个默认分组': 'No routing policies. Create a default group to get started.',
'未填写描述': 'No description',
'设为默认': 'Set as default',
'启用策略': 'Enable policy',
'配置策略': 'Configure policy',
'未命名策略': 'Unnamed policy',
'设为系统默认': 'Set as system default',
'新调度策略': 'New routing policy',
'例如:默认策略 / 高推理策略 / 号池优先策略': 'For example: Default / High reasoning / Pool priority',
'这些选项作用于当前调度策略。': 'These options apply to the current routing policy.',
'错误重试次数': 'Error retry count',
'CF保持心跳': 'CF keepalive',
'Cyber继续转移': 'Fail over on Cyber errors',
'调度配置': 'Routing configuration',
'先选择调度维度,再配置优先级模式、调度策略和提供商排序。': 'Choose the routing scope, then configure the priority mode, routing strategy, and provider order.',
'调度维度': 'Routing scope',
'统一调度': 'Unified routing',
'区分模型': 'Per-model routing',
'优先级模式': 'Priority mode',
'请先在下方选择一个模型,再配置该模型的优先级模式和调度策略。': 'Select a model below, then configure its priority mode and routing strategy.',
'统一作用于当前策略的所有模型': 'Applies to all models in this policy',
'按模型配置': 'Configure by model',
'选择模型后,在下方配置该模型的提供商排序。': 'Select a model to configure its provider order below.',
'搜索模型': 'Search models',
'正在加载模型': 'Loading models',
'未匹配到模型': 'No matching models',
'暂无已配置模型': 'No configured models',
'暂无未配置模型': 'No unconfigured models',
'加载其他模型配置': 'Load another model configuration',
'保存到草稿': 'Save to draft',
'移除当前模型排序': 'Remove the current model order',
'当前有未保存改动,不能移除': 'Save or discard changes before removing',
'仅作用于': 'Applies only to',
'未找到调度策略': 'Routing policy not found',
'返回分组': 'Back to groups',
'切换模型': 'Switch model',
'当前模型有未保存的改动,切换将丢弃这些改动,是否继续?': 'This model has unsaved changes. Switching models will discard them. Continue?',
'删除调度策略': 'Delete routing policy',
'确认删除调度策略「': 'Delete routing policy "',
'」?此操作无法撤销。': '"? This cannot be undone.',
'配置 OAuth Providers(登录/绑定)': 'Configure OAuth providers for sign-in and account linking',
'暂无配置': 'No configurations',
'新配置': 'New configuration',
'未保存': 'Unsaved',
'新建配置': 'Create configuration',
'填写后点击保存': 'Complete the configuration, then save',
'例如:My OIDC Provider': 'For example: My OIDC Provider',
'配置标识': 'Configuration identifier',
'请输入 secret': 'Enter the secret',
'Redirect URI(后端回调)': 'Redirect URI (server callback)',
'登录页显示的 Provider 图标,留空使用默认图标': 'Provider icon shown on the sign-in page. Leave empty to use the default icon.',
'留空使用默认值': 'Leave empty to use the default value',
'空格/逗号分隔;留空使用默认值': 'Separate values with spaces or commas. Leave empty to use defaults.',
'自定义 OIDC 必填;填写 Authorization / Token / Userinfo URL 所属域名。': 'Required for custom OIDC. Enter the domains used by the authorization, token, and userinfo URLs.',
'测试结果': 'Test results',
'配置 LDAP 认证服务': 'Configure LDAP authentication',
'LDAP 服务器配置': 'LDAP server settings',
'配置 LDAP 服务器连接参数': 'Configure the LDAP server connection',
'测试连接': 'Test connection',
'格式: ldap://host:389 或 ldaps://host:636': 'Format: ldap://host:389 or ldaps://host:636',
'绑定 DN': 'Bind DN',
'用于连接 LDAP 服务器的管理员 DN': 'Administrator DN used to connect to the LDAP server',
'绑定密码': 'Bind password',
'绑定账号的密码': 'Password for the bind account',
'基础 DN': 'Base DN',
'用户搜索的基础 DN': 'Base DN for user searches',
'用户搜索过滤器': 'User search filter',
'{username} 会被替换为登录用户名': '{username} is replaced with the sign-in username',
'用户名属性': 'Username attribute',
'常用: uid (OpenLDAP), sAMAccountName (AD)': 'Common values: uid (OpenLDAP), sAMAccountName (AD)',
'邮箱属性': 'Email attribute',
'显示名称属性': 'Display name attribute',
'连接超时 (秒)': 'Connection timeout (seconds)',
'使用 STARTTLS': 'Use STARTTLS',
'在非 SSL 连接上启用 TLS 加密': 'Enable TLS encryption on a non-SSL connection',
'启用 LDAP 认证': 'Enable LDAP authentication',
'允许用户使用 LDAP 账号登录': 'Allow users to sign in with LDAP accounts',
'仅允许 LDAP 登录': 'Allow LDAP sign-in only',
'禁用本地账号登录,仅允许 LDAP 认证': 'Disable local account sign-in and require LDAP authentication',
'配置邮件发送服务和注册邮箱限制': 'Configure outgoing email and registration email restrictions',
'SMTP 服务器地址': 'SMTP server address',
'SMTP 端口': 'SMTP port',
'SMTP 用户名': 'SMTP username',
'SMTP 密码': 'SMTP password',
'邮箱密码或应用专用密码': 'Email password or app-specific password',
'显示为发件人的邮箱地址': 'Email address shown as the sender',
'显示为发件人的名称': 'Name shown as the sender',
'注册邮箱验证': 'Registration email verification',
'需要邮箱验证': 'Require email verification',
'逗号分隔,例如: gmail.com, outlook.com, qq.com': 'Comma-separated, for example: gmail.com, outlook.com, qq.com',
'(已自定义)': '(Customized)',
'HTML 模板': 'HTML template',
'重置为默认': 'Reset to default',
'正在加载模板...': 'Loading template...',
'主题:': 'Subject:',
'(无主题)': '(No subject)',
'收件人:': 'Recipient:',
'实时性能监控与历史延迟趋势': 'Live performance monitoring and historical latency trends',
'实时 10s 刷新': 'Refreshes every 10s',
'上次更新': 'Last updated',
'刷新实时与历史性能数据': 'Refresh live and historical performance data',
'聚合系统健康、并发保护、代理通道与降级切换': 'System health, concurrency protection, proxy tunnels, and failover',
'加载实时性能数据中': 'Loading live performance data',
'实时性能数据暂不可用,请稍后重试。': 'Live performance data is temporarily unavailable. Try again later.',
'全局': 'Global',
'当前节点': 'Current node',
'本机': 'Local',
'可接入': 'Available capacity',
'被限流': 'Rate limited',
'全局保护': 'Global protection',
'全局并发保护暂不可用,请检查 Redis 连接。': 'Global concurrency protection is unavailable. Check the Redis connection.',
'实时连接': 'Live connections',
'节点数': 'Nodes',
'可用连接': 'Available connections',
'活跃流': 'Active streams',
'避让连接': 'Avoided connections',
'代理通道压力': 'Proxy tunnel load',
'排队': 'Queued',
'峰值排队': 'Peak queue size',
'队列满拒绝': 'Rejected: queue full',
'无可用通道': 'No available tunnel',
'降级切换统计': 'Failover statistics',
'当前没有记录到降级切换。': 'No failovers recorded.',
'收起最近错误': 'Collapse recent errors',
'展开最近错误': 'Expand recent errors',
'展开': 'Expand',
'当前没有最近错误。': 'No recent errors.',
'未知上游': 'Unknown upstream',
'未知格式': 'Unknown format',
'熔断历史与建议': 'Circuit breaker history and recommendations',
'开路': 'Open',
'当前没有熔断事件。': 'No circuit breaker events.',
'已打开': 'Open',
'半开': 'Half-open',
'原因:': 'Reason:',
'未提供': 'Not provided',
'恢复窗口:': 'Recovery window:',
'建议': 'Recommendations',
'当前没有额外运维建议。': 'No additional operational recommendations.',
'上游服务性能': 'Upstream service performance',
'清除上游服务性能筛选': 'Clear upstream performance filters',
'上游 ID': 'Upstream ID',
'端点类型': 'Endpoint type',
'全部流式': 'All streaming modes',
'仅流式': 'Streaming only',
'全部转换': 'All conversion modes',
'仅转换': 'Converted only',
'不转换': 'Not converted',
'慢请求阈值 ms': 'Slow request threshold (ms)',
'上游服务': 'Upstream service',
'P90/P99 响应': 'P90/P99 response time',
'P90/P99 首字': 'P90/P99 time to first token',
'样本覆盖': 'Sample coverage',
'当前没有上游服务性能数据。': 'No upstream performance data.',
'输出 TPS 趋势': 'Output TPS trend',
'平均首字趋势': 'Average time to first token',
'响应延迟百分位': 'Response latency percentiles',
'首字节延迟百分位': 'Time to first byte percentiles',
'错误分布': 'Error distribution',
'错误趋势': 'Error trends',
'成本趋势、预测与节省统计': 'Cost trends, forecasts, and savings',
'缓存节省': 'Cache savings',
'读取成本': 'Read cost',
'缓存读取 Tokens': 'Cache read tokens',
'预计全额成本': 'Estimated cost without caching',
'缓存创建成本': 'Cache write cost',
'基于当前时间范围': 'Based on the selected time range',
'成本趋势预测': 'Cost trend forecast',
'月卡消耗进度': 'Monthly plan usage',
'API Key 用量排行': 'API key usage ranking',
'活跃亲和性': 'Active affinities',
'Provider 切换': 'Provider switches',
'Key 切换': 'Key switches',
'缓存失效': 'Cache invalidations',
'因 Provider 不可用': 'Due to provider unavailability',
'亲和性列表': 'Affinities',
'搜索用户或 Key': 'Search users or keys',
'清除全部缓存': 'Clear all caches',
'API 格式 / Key': 'API format / Key',
'独立': 'Independent',
'此缓存源没有精确次数统计': 'Exact counts are unavailable for this cache source',
'清除缓存': 'Clear cache',
'暂无缓存记录': 'No cache records',
'模型映射缓存': 'Model mapping cache',
'清除全部映射缓存': 'Clear all mapping caches',
'映射模型': 'Mapped model',
'未映射的缓存条目': 'Unmapped cache entries',
'- 点击清除': '- Click to clear',
'Provider 模型映射缓存': 'Provider model mapping cache',
'请求名称': 'Requested name',
'暂无模型解析缓存': 'No model resolution cache',
'Redis 未启用': 'Redis is disabled',
'Redis 缓存管理': 'Redis cache management',
'刷新缓存分类': 'Refresh cache categories',
'清除该分类缓存': 'Clear this cache category',
'正在扫描 Redis 缓存...': 'Scanning Redis cache...',
'TTL 分析': 'TTL analysis',
'时间段': 'Time period',
'tokens 命中': 'tokens hit',
'缓存创建费用': 'Cache write cost',
'使用频率': 'Usage frequency',
'推荐 TTL': 'Recommended TTL',
'未知用户': 'Unknown user',
'0-5分钟': '0-5 minutes',
'5-15分钟': '5-15 minutes',
'15-30分钟': '15-30 minutes',
'30-60分钟': '30-60 minutes',
'>60分钟': '>60 minutes',
'个数据点': 'data points',
'未找到符合条件的用户数据': 'No user data matches these criteria',
'尝试增加分析天数或降低最小请求数阈值': 'Try increasing the analysis period or lowering the minimum request threshold',
'黑名单状态不可用,列表可能不是最新': 'Blacklist status is unavailable. The list may be outdated.',
'当前共有': 'Currently',
'个 IP 在黑名单中': 'IP addresses are blacklisted',
'剩余时长': 'Time remaining',
'管理可信任的 IP 地址(支持 CIDR 格式)': 'Manage trusted IP addresses (CIDR supported)',
'IP 地址 / CIDR': 'IP address / CIDR',
'添加 IP 到黑名单': 'Blacklist an IP address',
'被加入黑名单的 IP 将无法访问任何接口': 'Blacklisted IP addresses cannot access any endpoint',
'例如: 192.168.1.100': 'For example: 192.168.1.100',
'加入黑名单的原因': 'Reason for blacklisting',
'过期时间(可选)': 'Expiration (optional)',
'留空表示永久,单位:秒': 'Duration in seconds; leave empty for no expiration',
'留空表示永久封禁,或输入秒数(如 3600 表示 1 小时)': 'Leave empty for a permanent ban, or enter seconds (for example, 3600 for one hour)',
'添加到黑名单': 'Add to blacklist',
'添加 IP 到白名单': 'Whitelist an IP address',
'白名单中的 IP 不受速率限制': 'Whitelisted IP addresses are exempt from rate limits',
'IP 地址或 CIDR': 'IP address or CIDR',
'例如: 192.168.1.0/24 或 192.168.1.100': 'For example: 192.168.1.0/24 or 192.168.1.100',
'支持单个 IP 或 CIDR 网段格式': 'Supports individual IP addresses and CIDR ranges',
'添加到白名单': 'Add to whitelist',
'全部分类': 'All categories',
'赠款': 'Gift credit',
'调账': 'Adjustment',
'充': 'Paid',
'· 赠': '· Gift',
'退款状态': 'Refund status',
'待审批': 'Pending approval',
'已审批': 'Approved',
'已失败': 'Failed',
'审批': 'Approve',
'暂无退款申请': 'No refund requests',
'当前筛选条件下没有退款单': 'No refunds match the selected filters',
'订单状态': 'Order status',
'待支付': 'Awaiting payment',
'已支付': 'Paid',
'已到账': 'Credited',
'充值卡': 'Top-up card',
'礼品卡': 'Gift card',
'卡密充值': 'Code top-up',
'钱包名称': 'Wallet name',
'到账': 'Credit',
'暂无支付订单': 'No payment orders',
'回调键': 'Callback key',
'方式': 'Method',
'验签': 'Signature verification',
'通过': 'Passed',
'暂无回调日志': 'No callback logs',
'批量生成兑换码': 'Generate redemption codes in bulk',
'生成后本会话可切换显示明文;页面刷新后仅保留脱敏码。': 'Newly generated codes can be revealed during this session. After a page refresh, only masked codes remain.',
'批次名称': 'Batch name',
'面额 (USD)': 'Face value (USD)',
'生成数量': 'Number to generate',
'备注(可选)': 'Notes (optional)',
'例如:五一活动 / 线下渠道 / KOC 发放': 'For example: Holiday campaign / Offline channel / Community distribution',
'导出最近生成': 'Export latest batch',
'生成兑换码': 'Generate redemption codes',
'最近生成批次:': 'Latest generated batch:',
'个兑换码': 'redemption codes',
'批次状态': 'Batch status',
'个批次': 'batches',
'批次': 'Batch',
'面额': 'Face value',
'数量': 'Quantity',
'已使用': 'Used',
'当前查看': 'Currently viewing',
'查看码': 'View codes',
'停用批次': 'Disable batch',
'删除批次': 'Delete batch',
'暂无兑换码批次': 'No redemption code batches',
'创建批次后会在这里显示': 'Created batches will appear here',
'兑换码列表': 'Redemption codes',
'· 剩余': '· Remaining',
'先从左侧选择一个批次': 'Select a batch on the left',
'显示明文': 'Reveal codes',
'码状态': 'Code status',
'已兑换': 'Redeemed',
'当前批次属于本次生成,已支持明文显示开关。': 'This batch was generated in the current session and its codes can be revealed.',
'仅当前会话内最近生成的一批兑换码支持明文显示;其余批次仅显示脱敏码。': 'Only the latest batch generated in this session can be revealed. Other batches show masked codes.',
'兑换用户': 'Redeemed by',
'关联订单': 'Linked order',
'暂无兑换码': 'No redemption codes',
'选择左侧批次后会显示兑换码明细': 'Select a batch on the left to view its codes',
'流水详情': 'Transaction details',
'资金动作审计信息': 'Financial transaction audit details',
'关联类型': 'Reference type',
'交易ID': 'Transaction ID',
'充值订单号': 'Top-up order number',
'操作用户': 'Performed by',
'已删除用户': 'Deleted user',
'系统自动': 'System',
'邮箱:': 'Email:',
'退款单:': 'Refund:',
'申请原因': 'Request reason',
'失败原因': 'Failure reason',
'驳回原因': 'Rejection reason',
'请填写驳回原因': 'Enter a reason for rejection',
'网关退款号(可选)': 'Gateway refund ID (optional)',
'打款凭证 / 参考号(可选)': 'Transfer receipt / Reference number (optional)',
'处理退款': 'Process refund',
'确认完成': 'Confirm completion',
'驳回退款': 'Reject refund',
'人工到账': 'Manual credit',
'订单:': 'Order:',
'网关订单号(可选)': 'Gateway order ID (optional)',
'实付金额(可选)': 'Amount paid (optional)',
'币种(可选)': 'Currency (optional)',
'汇率(可选)': 'Exchange rate (optional)',
'确认到账': 'Confirm credit',
'正在加载支付配置...': 'Loading payment configuration...',
'支付宝支付模式说明': 'About Alipay payment modes',
',前台直接渲染二维码。': '; the frontend displays the QR code directly.',
',并继续以返回链接渲染成二维码。': '; the returned link is displayed as a QR code.',
',跳转支付宝收银台。': '; redirects to Alipay checkout.',
'微信支付模式说明': 'About WeChat Pay payment modes',
'Native / 扫码支付': 'Native / QR code payment',
'易支付接口地址': 'Epay API URL',
'(留空保持不变)': '(Leave empty to keep unchanged)',
'USD 汇率': 'USD exchange rate',
'最低充值金额 (USD)': 'Minimum top-up amount (USD)',
'移除通道': 'Remove channel',
'/ 待冲回': '/ Pending reversal',
'补发': 'Reissue',
'作废': 'Void',
'暂无返利记录': 'No rebate records',
'搜索用户名...': 'Search usernames...',
'筛选时间范围': 'Filter by time range',
'筛选事件类型': 'Filter by event type',
'无描述': 'No description',
'审计日志详情': 'Audit log details',
'状态码': 'Status code',
'错误消息': 'Error message',
'元数据': 'Metadata',
'重试中': 'Retrying',
'已跳过': 'Skipped',
'暂无异步任务': 'No asynchronous tasks',
'用户/Provider': 'User / Provider',
'任务详情': 'Task details',
'开启自动刷新(每5秒)': 'Enable automatic refresh (every 5 seconds)',
'错误码:': 'Error code:',
'大小:': 'Size:',
'视频链接不可用或已过期': 'The video link is unavailable or has expired',
'视频信息': 'Video information',
'视频时长': 'Video duration',
'宽高比': 'Aspect ratio',
'尺寸': 'Dimensions',
'执行状态': 'Execution status',
'轮询': 'Polling',
'轮询间隔': 'Polling interval',
'下次轮询': 'Next poll',
'时间范围': 'Time range',
'响应数据': 'Response data',
'取消任务': 'Cancel task',
'条记录': 'records',
'启用模型后缀参数模块': 'Enable the model suffix parameters module',
'管理系统功能模块的启用状态': 'Manage enabled system modules',
'搜索模块名称或描述...': 'Search module names or descriptions...',
'正在保存排序': 'Saving order',
'模块不可用,请检查环境变量和依赖库': 'Module unavailable. Check environment variables and dependencies.',
'请先完成配置': 'Complete the configuration first',
'没有找到匹配的模块': 'No matching modules',
'暂无可管理的模块': 'No modules available to manage',
'开启后,跨 API 格式转换的候选不会被降级到同格式候选之后;Provider 自身的同名开关仍单独生效。': 'When enabled, candidates requiring API format conversion are not ranked below candidates using the same format. The provider-level setting remains independent.',
'首个候选(缓存亲和命中的 Key)的总尝试次数。2 表示失败后同 Key 重试 1 次再转移;0 或 1 表示不重试。': 'Total attempts for the first candidate, including a key matched by cache affinity. A value of 2 retries the same key once before failing over; 0 or 1 disables retries.',
'同步生图和标准文本非流式失败时保持外层 HTTP 状态为 200,并在响应体中返回错误。': 'For synchronous image generation and standard non-streaming text failures, keep the HTTP status at 200 and return the error in the response body.',
'响应开始前遇到 Cyber Policy 错误时继续故障转移。': 'Continue failover if a Cyber Policy error occurs before the response starts.',
'旧余额权益': 'Legacy wallet credit benefit',
'旧余额套餐': 'Legacy wallet credit plan',
'纯余额套餐已由钱包充值功能承接。建议停用该套餐,或补充每日额度/会员分组后作为混合套餐。': 'Wallet top-ups now replace credit-only plans. Disable this plan, or add daily credit or membership groups to make it a bundled plan.',
'新建套餐不再提供余额包模板': 'Credit-only templates are no longer available for new plans',
'钱包入账请使用充值功能': 'Use top-ups to add wallet credit',
'附赠余额仍可放在混合套餐中': 'Bundled plans can still include bonus wallet credit',
'周期额度': 'Periodic credit',
'适合月卡、季卡、年卡。周期内每天给用户独立 USD 额度,到期后不再生效。': 'Suitable for monthly, quarterly, or annual plans. Grants a separate USD allowance each day during the plan period, ending at expiration.',
'默认每天按重置时区刷新': 'Resets daily in the configured time zone by default',
'默认用完后拒绝继续消费': 'Further spending is blocked when credit is exhausted by default',
'同一用户只保留一个有效每日额度套餐': 'Each user can have one active daily credit plan',
'会员权限': 'Membership permissions',
'适合 Pro、Plus、团队会员。购买后动态合并用户分组权限,到期自然失效。': 'Suitable for Pro, Plus, or team memberships. Adds group permissions after purchase and removes them at expiration.',
'不会永久修改用户基础分组': 'Does not permanently change the user\'s base groups',
'同一用户只保留一个有效会员权益包': 'Each user can have one active membership plan',
'适合解锁模型组或高级功能': 'Suitable for unlocking model groups or advanced features',
'流量策略': 'Usage policy',
'使用限制套餐': 'Usage limit plan',
'按套餐限制请求频率、周期请求数和同时进行的请求数,多条规则可以自由组合。': 'Limit request frequency, requests per period, and concurrent requests. Combine multiple rules as needed.',
'任意规则触顶都会拒绝新请求': 'New requests are rejected when any limit is reached',
'QPS 和 RPM 使用滚动窗口': 'QPS and RPM use rolling windows',
'策略随套餐权益到期失效': 'The policy expires with the plan benefits',
'组合权益套餐': 'Bundled benefit plan',
'适合同时包含额度、会员权限和流量限制的产品,也可以按需附赠少量钱包余额。': 'Combines credit, membership permissions, and usage limits, with optional bonus wallet credit.',
'包含每日额度或会员权益时,同类旧套餐会整包失效': 'Daily credit or membership benefits replace the entire existing plan of the same type',
'附赠余额发放后不随周期结束扣回': 'Granted bonus wallet credit is not reclaimed at the end of the period',
'限购会同时影响整套组合权益': 'Purchase limits apply to the entire benefit bundle',
'待配置': 'Not configured',
'选择一种套餐模板': 'Select a plan template',
'先选择每日额度、会员分组、流量限制或混合套餐,再配置价格、购买限制和权益。': 'Select daily credit, membership groups, usage limits, or a bundled plan, then configure pricing, purchase limits, and benefits.',
'每日额度和会员权益是周期权益': 'Daily credit and membership benefits apply for a fixed period',
'钱包充值已从套餐中拆出': 'Wallet top-ups are managed separately from plans',
'混合套餐可以附赠余额': 'Bundled plans can include bonus wallet credit',
'旧余额套餐仍会保存这个有效期,但余额发放后不会在到期时扣回;建议改用钱包充值功能。': 'Legacy wallet credit plans retain this validity period, but granted credit is not reclaimed at expiration. Use wallet top-ups instead.',
'购买后套餐权益生效这么久,与重复购买限制的统计方式相互独立。自定义单位按天计算。': 'How long the benefits remain active after purchase. This is independent of repeat purchase limits. Custom durations are measured in days.',
'每人最多购买次数': 'Maximum purchases per user',
'每人最多同时生效': 'Maximum active plans per user',
'按同一用户历史成功购买次数累计,达到该值后不能再次购买,适合首购特惠包。': 'Counts all successful purchases by the same user. Further purchases are blocked at this limit, making it suitable for introductory offers.',
'只统计仍在周期内的已生效权益,过期后释放名额,用于防止周期权益无限叠加。': 'Counts only benefits that are still active. Expiration frees a slot, preventing unlimited stacking of periodic benefits.',
'不检查同一用户的重复购买次数;每次支付成功都会按下方权益配置发放。': 'Does not limit repeat purchases. Each successful payment grants the benefits configured below.',
'作为套餐附赠余额一次性发放': 'Granted once as bonus wallet credit with the plan',
'旧余额套餐会一次性发放到充值余额或赠款余额': 'Legacy credit plans grant a one-time amount to the paid or gift balance',
'组合套餐内的周期性每日 USD 消费用量': 'Daily USD spending allowance within a bundled plan',
'每天独立 USD 消费用量,默认不结转': 'A separate USD spending allowance each day, without rollover by default',
'每日额度不足时会继续使用钱包余额,适合希望用户不中断请求的套餐。': 'Uses wallet funds when daily credit is insufficient, allowing users to continue making requests.',
'每日额度不足时不再继续扣钱包,适合严格封顶的月卡或体验卡。': 'Does not charge the wallet when daily credit is insufficient. Suitable for monthly or trial plans with strict spending caps.',
'组合套餐内的动态会员权限': 'Dynamic membership permissions within a bundled plan',
'购买后动态合并分组权限,到期自动失效': 'Adds group permissions after purchase and removes them at expiration',
'这里授予的是动态分组权限,不会永久改写用户基础分组;权益到期后权限解析会自动移除。': 'Grants dynamic group permissions without permanently changing the user\'s base groups. These permissions are removed automatically when the benefit expires.',
'窗口秒数': 'Window duration in seconds',
'时区 / 周起始日': 'Time zone / Start of week',
'时区(可选)': 'Time zone (optional)',
'窗口参数': 'Window parameters',
'请输入套餐名称': 'Enter a plan name',
'价格必须大于 0': 'Price must be greater than 0',
'价格最多支持两位小数': 'Price supports up to two decimal places',
'请输入价格币种': 'Enter the pricing currency',
'重复购买限制必须是按周期限制、永久限制或不限购': 'Repeat purchases must use a per-period limit, a lifetime limit, or no limit',
'权益有效期单位必须是日/月/年/自定义天数': 'Benefit validity must use days, months, years, or a custom number of days',
'权益有效期必须是正整数': 'Benefit validity must be a positive integer',
'至少启用一种权益': 'Enable at least one benefit',
'套餐至少需要包含每日额度、会员分组或使用限制;钱包充值请使用充值功能': 'A plan must include daily credit, membership groups, or usage limits. Use top-ups for wallet credit.',
'附赠余额金额必须大于 0': 'Bonus wallet credit must be greater than 0',
'每日额度必须大于 0': 'Daily credit must be greater than 0',
'会员分组权益至少选择一个分组': 'Select at least one membership group',
'附赠余额的套餐互斥组不能超过 128 个字符': 'The bonus credit exclusivity group cannot exceed 128 characters',
'每日额度的套餐互斥组不能超过 128 个字符': 'The daily credit exclusivity group cannot exceed 128 characters',
'会员权益的套餐互斥组不能超过 128 个字符': 'The membership exclusivity group cannot exceed 128 characters',
'至少添加一份使用限制策略': 'Add at least one usage limit policy',
'不限制重复购买': 'No repeat purchase limit',
'不按周期重置购买次数': 'Purchase counts do not reset periodically',
'旧余额套餐,建议停用': 'Legacy wallet credit plan; disabling is recommended',
'每日额度周期': 'Daily credit period',
'会员权限周期': 'Membership period',
'使用限制周期': 'Usage limit period',
'组合权益周期': 'Bundled benefit period',
'未配置权益': 'No benefits configured',
'未知权益': 'Unknown benefit',
'已设为默认调度策略': 'Set as the default routing policy',
'调度策略已启用': 'Routing policy enabled',
'调度策略已禁用': 'Routing policy disabled',
'调度策略顺序已更新': 'Routing policy order updated',
'调度策略已保存': 'Routing policy saved',
'当前模型配置已保存到草稿,点击外层保存后生效': 'Model configuration saved to the draft. Save the policy to apply it.',
'调度策略已删除': 'Routing policy deleted',
'加载 LDAP 配置失败': 'Failed to load LDAP configuration',
'LDAP 配置保存成功': 'LDAP configuration saved',
'保存 LDAP 配置失败': 'Failed to save LDAP configuration',
'LDAP 连接测试成功': 'LDAP connection test succeeded',
'LDAP 连接测试失败': 'LDAP connection test failed',
'是否需要邮箱验证': 'Require email verification',
'邮箱后缀限制模式': 'Email domain restriction mode',
'加载邮件模板失败': 'Failed to load email template',
'模板保存成功': 'Template saved',
'保存模板失败': 'Failed to save template',
'预览模板失败': 'Failed to preview template',
'模板已重置为默认值': 'Template reset to default',
'重置模板失败': 'Failed to reset template',
'加载邮件配置失败': 'Failed to load email configuration',
'是否使用 TLS 加密': 'Use TLS encryption',
'是否使用 SSL 加密': 'Use SSL encryption',
'SMTP 配置已保存': 'SMTP configuration saved',
'SMTP 连接测试成功': 'SMTP connection test succeeded',
'SMTP 连接测试失败': 'SMTP connection test failed',
'错误数': 'Errors',
'健康状态未知': 'Health status unknown',
'系统健康': 'System health',
'系统降级': 'System degraded',
'系统告警': 'System warning',
'网关指标在线': 'Gateway metrics available',
'网关指标暂不可达': 'Gateway metrics unavailable',
'尚未刷新': 'Not yet refreshed',
'请求样本': 'Request samples',
'P99 响应': 'P99 response time',
'P99 首字': 'P99 time to first token',
'最近 1 小时错误': 'Errors in the last hour',
'当前节点处理中': 'In progress on this node',
'检查 Redis 连接': 'Check the Redis connection',
'当前进程累计': 'Current process total',
'警告:此操作会清除所有用户的缓存亲和性,确定继续吗?': 'This clears cache affinity for all users. Continue?',
'再次确认': 'Confirm again',
'这将影响所有用户,请再次确认!': 'This affects all users. Please confirm again.',
'OAuth 认证': 'OAuth authentication',
'确定要清除所有模型映射缓存吗?这会影响所有模型的名称解析。': 'Clear all model mapping caches? This affects name resolution for all models.',
'Provider 模型映射': 'Provider model mappings',
'无法从白名单移除 IP': 'Unable to remove the IP address from the whitelist',
'无法从黑名单移除 IP': 'Unable to remove the IP address from the blacklist',
'永久': 'Permanent',
'初始赠款': 'Initial gift credit',
'活动赠款': 'Promotional gift credit',
'赠款回收': 'Gift credit reversal',
'系统调账': 'System adjustment',
'退款扣减': 'Refund debit',
'退款回补': 'Refund recredit',
'兑换码批次已创建': 'Redemption code batch created',
'CSV 已导出': 'CSV exported',
'批次已停用': 'Batch disabled',
'批次已删除': 'Batch deleted',
'兑换码已停用': 'Redemption code disabled',
'未知钱包': 'Unknown wallet',
'未知归属': 'Unknown owner',
'退款已进入 processing': 'Refund is now processing',
'退款已驳回': 'Refund rejected',
'退款已完成': 'Refund completed',
'订单已手动到账': 'Order credited manually',
'订单已标记过期': 'Order marked as expired',
'订单已标记失败': 'Order marked as failed',
'未命名用户': 'Unnamed user',
'商户 ID': 'Merchant ID',
'请输入商户密钥': 'Enter the merchant secret',
'用于支付宝官方当面付、手机网站支付或电脑网站支付': 'For official Alipay in-person, mobile website, or desktop website payments',
'PKCS#1 或 PKCS#8 私钥': 'PKCS#1 or PKCS#8 private key',
'支付宝公钥': 'Alipay public key',
'支付宝开放平台公钥': 'Alipay Open Platform public key',
'用于微信支付 Native/H5JSAPI 还需要后续接入 OpenID 获取流程': 'For WeChat Pay Native and H5 payments. JSAPI also requires an OpenID acquisition flow.',
'商户证书序列号': 'Merchant certificate serial number',
'微信支付公钥 ID': 'WeChat Pay public key ID',
'商户 API 私钥': 'Merchant API private key',
'API v3 密钥': 'API v3 key',
'32 位 API v3 key': '32-character API v3 key',
'微信支付公钥': 'WeChat Pay public key',
'用于 Stripe PaymentIntentWebhook Secret 用于回调验签': 'For Stripe PaymentIntent. The webhook secret verifies callback signatures.',
'请输入易支付接口地址': 'Enter the Epay API URL',
'请输入支付币种': 'Enter the payment currency',
'USD 汇率必须大于 0': 'The USD exchange rate must be greater than 0',
'最低充值金额必须大于 0': 'The minimum top-up amount must be greater than 0',
'至少需要一个支付通道': 'At least one payment channel is required',
'支付配置已保存': 'Payment configuration saved',
'支付配置可用': 'Payment configuration is valid',
'返利已补发': 'Rebate reissued',
'返利已作废': 'Rebate voided',
'登录失败': 'Sign-in failed',
'API密钥创建': 'API key created',
'API密钥删除': 'API key deleted',
'请求成功': 'Request succeeded',
'请求失败': 'Request failed',
'用户更新': 'User updated',
'用户删除': 'User deleted',
'1天': '1 day',
'7天': '7 days',
'30天': '30 days',
'90天': '90 days',
'请选择 Key': 'Select a key',
'请至少选择一个 Key 来上传文件': 'Select at least one key for the file upload',
'上传成功': 'Upload succeeded',
'上传失败': 'Upload failed',
'所有 Key 上传都失败了': 'Uploads failed for all keys',
'配置异常': 'Configuration error',
'已开启': 'Enabled',
'获取模块列表失败': 'Failed to load modules',
'模块已启用': 'Module enabled',
'模块已禁用': 'Module disabled',
'模块顺序已保存': 'Module order saved',
'保存模块顺序失败': 'Failed to save module order',
'获取模型后缀参数模块状态失败': 'Failed to load the model suffix parameters module status',
'获取模型后缀参数配置失败': 'Failed to load model suffix parameter settings',
'模型后缀参数配置已保存': 'Model suffix parameter settings saved',
'保存模型后缀参数配置失败': 'Failed to save model suffix parameter settings',
'模型后缀参数模块已启用': 'Model suffix parameters module enabled',
'模型后缀参数模块已禁用': 'Model suffix parameters module disabled',
'更新模型后缀参数模块状态失败': 'Failed to update the model suffix parameters module status',
}
+112
View File
@@ -0,0 +1,112 @@
export const legacyGuideEnglishMessages: Record<string, string> = {
'Aether 官方文档': 'Aether documentation',
'1. 项目部署': '1. Deployment',
'2. 配置流程': '2. Configuration workflow',
'1. 创建统一模型': '1. Create a unified model',
'2. 添加提供商': '2. Add a provider',
'3. 添加端点': '3. Add an endpoint',
'添加端点 1': 'Add an endpoint: step 1',
'添加端点 2': 'Add an endpoint: step 2',
'4. 添加密钥': '4. Add an API key',
'5. 关联全局模型': '5. Link a global model',
'关联全局模型 1': 'Link a global model: step 1',
'关联全局模型 2': 'Link a global model: step 2',
'6. 模型映射': '6. Model mapping',
'3. 反向代理': '3. Reverse proxy',
'Social 格式要求': 'Social format requirements',
'IDC 格式要求': 'IDC format requirements',
'反向代理配置示例': 'Reverse proxy configuration example',
'4. 异步任务': '4. Asynchronous tasks',
'5. 代理配置': '5. Proxy configuration',
'2. 代理节点': '2. Proxy nodes',
'3. 多级代理': '3. Proxy hierarchy',
'全局代理 - 系统配置': 'Global proxy - System settings',
'提供商代理 - 提供商配置': 'Provider proxy - Provider settings',
'Key代理 - Key配置': 'Key proxy - Key settings',
'提供商类型:': 'Provider type:',
'最大重试次数:': 'Maximum retries:',
'超时时间:': 'Timeout:',
'保持优先级:': 'Preserve priority:',
'选择格式:': 'Select format:',
'选择上游支持的端点格式。': 'Select an endpoint format supported by the upstream service.',
'添加端点选择格式': 'Select a format when adding an endpoint',
'端点自定义': 'Endpoint customization',
'认证类型:': 'Authentication type:',
'缓存TTL': 'Cache TTL:',
'熔断探测:': 'Circuit breaker probes:',
'添加密钥认证': 'Configure key authentication',
'密钥倍率与优先级': 'Key multiplier and priority',
'5. 模型权限': '5. Model permissions',
'模型权限 1': 'Model permissions: step 1',
'模型权限 2': 'Model permissions: step 2',
'模型权限 3': 'Model permissions: step 3',
'6. 关联模型': '6. Link models',
'关联模型 1': 'Link models: step 1',
'关联模型 2': 'Link models: step 2',
'关联模型 3': 'Link models: step 3',
'7. 模型映射': '7. Model mapping',
'名称修正示例:': 'Name correction example:',
',实际提供商叫': ', while the provider calls it',
'降/升级请求示例:': 'Model substitution example:',
',实际请求提供商用': ', while the upstream request uses',
'模型映射 1': 'Model mapping: step 1',
'模型映射 2': 'Model mapping: step 2',
'8. 反向代理': '8. Reverse proxy',
'Codex 反向代理': 'Codex reverse proxy',
'Krio 反向代理': 'Krio reverse proxy',
'Antigravity 反向代理': 'Antigravity reverse proxy',
'9. 优先级管理': '9. Priority management',
'1. 提供商优先': '1. Provider priority',
'2. Key优先': '2. Key priority',
'3. 缓存亲和模式': '3. Cache affinity mode',
'4. 负载均衡模式': '4. Load balancing mode',
'5. 固定顺序模式': '5. Fixed order mode',
'优先级管理 1': 'Priority management: step 1',
'优先级管理 2': 'Priority management: step 2',
'1. 请求体记录': '1. Request body logging',
'Base + 请求头 (Headers)': 'Base + request headers',
'Headers + 完整的请求体与响应体 (Payloads)': 'Headers + full request and response bodies (payloads)',
'请求体记录设置': 'Request body logging settings',
'2. 调度模式': '2. Routing modes',
'提供商优先:': 'Provider priority:',
'Key优先:': 'Key priority:',
'缓存亲和:': 'Cache affinity:',
'负载均衡:': 'Load balancing:',
'固定顺序:': 'Fixed order:',
'故障转移:': 'Failover:',
'3. 访问限制': '3. Access restrictions',
'4. 请求体压缩清理': '4. Request body compression and cleanup',
'5. 定时任务': '5. Scheduled tasks',
'跨平台格式转换': 'Cross-platform format conversion',
'上游提供商': 'Upstream providers',
'1. 格式转换': '1. Format conversion',
'2. 请求上游固定非流/流式': '2. Force upstream streaming or non-streaming mode',
'3. 请求头/体编辑': '3. Edit request headers and bodies',
'4. 模型映射': '4. Model mapping',
'5. 正则映射': '5. Regex mapping',
'模型权限:': 'Model permissions:',
'6. 余额监控': '6. Balance monitoring',
'7. 配置导入/出': '7. Import and export configuration',
'8. 锁定用户密钥': '8. Lock user keys',
'1. 访问令牌': '1. Access tokens',
'2. 邮件配置': '2. Email configuration',
'3. OAuth 登录': '3. OAuth sign-in',
'4. LDAP 认证': '4. LDAP authentication',
'统一模型规范 / 协议聚合': 'Unified models / Protocol aggregation',
'配额管控': 'Quota controls',
'智能调度 / 故障转移': 'Smart routing / Failover',
'Claude 响应': 'Claude response',
'OpenAI 响应': 'OpenAI response',
'Gemini 响应': 'Gemini response',
'返回文档': 'Back to documentation',
'架构布局草案演示': 'Architecture layout drafts',
'多源聚合': 'Multi-source aggregation',
'鉴权 / 配额管控': 'Authentication / Quota controls',
'负载均衡 / 故障转移': 'Load balancing / Failover',
'基于亲和性路由': 'Affinity-based routing',
'3. 格式转换引擎': '3. Format conversion engine',
'4. 原生双向透传': '4. Native bidirectional pass-through',
'格式转换引擎': 'Format conversion engine',
'原生直通管道': 'Native pass-through channel',
'后端 + Vite dev server': 'Backend + Vite dev server',
}
+127 -309
View File
@@ -1,48 +1,17 @@
import { NodeTypes, parse as parseTemplate, type ElementNode, type TemplateChildNode } from '@vue/compiler-dom'
import type { Expression } from '@babel/types'
import { babelParse, MagicString, parse as parseSfc } from 'vue/compiler-sfc'
import type { Plugin } from 'vite'
const cjkPattern = /[\u4e00-\u9fff]/
const helperName = '__aetherLegacyT'
const helperImportName = '__useAetherI18n'
const skipTags = new Set(['script', 'style', 'code', 'pre', 'kbd', 'samp', 'textarea'])
const voidTags = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
])
const translatableAttributeNames = new Set([
'alt',
'aria-label',
'cancel-text',
'client-label',
'confirm-text',
'description',
'drop-title',
'empty-message',
'empty-text',
'entity-label',
'filter-title',
'label',
'manual-placeholder',
'message',
'path-hint',
'placeholder',
'provider-label',
'search-placeholder',
'subtitle',
'title',
'alt', 'aria-label', 'cancel-text', 'client-label', 'confirm-text', 'description',
'drop-title', 'empty-message', 'empty-text', 'entity-label', 'filter-title', 'label',
'manual-placeholder', 'message', 'path-hint', 'placeholder', 'provider-label',
'search-placeholder', 'subtitle', 'title',
])
interface TemplateTransformResult {
@@ -51,300 +20,159 @@ interface TemplateTransformResult {
needsHelper: boolean
}
interface TagInfo {
closing: boolean
name: string
selfClosing: boolean
skipSubtree: boolean
}
interface TagStackEntry {
name: string
skip: boolean
}
function toExpressionString(value: string): string {
return JSON.stringify(value).replace(/'/g, "\\'")
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/}/g, '\\u007d')
}
function wrapExpression(expression: string): string {
const trimmed = expression.trim()
if (!trimmed || trimmed.includes(helperName)) {
function escapeAttribute(expression: string): string {
return expression.replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;')
}
function isTranslatableAttribute(node: ElementNode, name: string): boolean {
const normalized = name.toLowerCase()
return translatableAttributeNames.has(normalized)
|| (normalized === 'text' && (node.tag === 'HelpHint' || node.tag === 'help-hint'))
}
function translateExpression(expression: string): string {
if (!cjkPattern.test(expression)) return expression
let parsed: Expression
try {
const statement = babelParse(`(${expression})`, { plugins: ['typescript'] }).program.body[0]
if (statement?.type !== 'ExpressionStatement') return expression
parsed = statement.expression
} catch {
// Vue reports invalid expressions with the original source location.
return expression
}
return `${helperName}(${trimmed})`
}
function renderTranslatedText(value: string): string {
return `{{ ${helperName}(${toExpressionString(value)}) }}`
}
function findTagEnd(source: string, start: number): number {
let quote: string | null = null
for (let index = start; index < source.length; index++) {
const char = source[index]
if (quote) {
if (char === quote) {
quote = null
}
continue
}
if (char === '"' || char === "'") {
quote = char
continue
}
if (char === '>') {
return index
}
}
return -1
}
function looksLikeTagStart(source: string, index: number): boolean {
const next = source[index + 1]
return !!next && /[A-Za-z!/]/.test(next)
}
function parseTagInfo(tag: string): TagInfo | null {
if (tag.startsWith('<!--') || tag.startsWith('<!') || tag.startsWith('<?')) {
return null
}
const match = tag.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:._-]*)/)
if (!match) {
return null
}
const name = match[2].toLowerCase()
const closing = !!match[1]
const selfClosing = closing ? false : /\/\s*>$/.test(tag) || voidTags.has(name)
const skipSubtree = !closing && (skipTags.has(name) || /\sv-pre(?:[\s=>]|$)/.test(tag))
return {
closing,
name,
selfClosing,
skipSubtree,
}
}
function isTranslatableAttribute(attributeName: string): boolean {
const normalized = attributeName
.replace(/^:/, '')
.replace(/^v-bind:/, '')
.split('.')[0]
.toLowerCase()
return translatableAttributeNames.has(normalized)
}
function isBoundAttribute(attributeName: string): boolean {
return attributeName.startsWith(':') || attributeName.startsWith('v-bind:')
}
function transformTagAttributes(tag: string): TemplateTransformResult {
let changed = false
let needsHelper = false
const attributePattern = /(\s)([:@]?[A-Za-z_][\w:.-]*)(\s*=\s*)(["'])([\s\S]*?)\4/g
const code = tag.replace(
attributePattern,
(fullMatch, prefix: string, attributeName: string, equals: string, quote: string, value: string) => {
if (!isTranslatableAttribute(attributeName) || attributeName.startsWith('@')) {
return fullMatch
}
if (isBoundAttribute(attributeName)) {
const wrapped = wrapExpression(value)
if (wrapped === value) {
return fullMatch
const code = new MagicString(expression)
const visit = (node: Expression): void => {
if (node.start == null || node.end == null) return
if (node.type === 'StringLiteral' && cjkPattern.test(node.value)) {
code.overwrite(node.start - 1, node.end - 1, `${helperName}(${toExpressionString(node.value)})`)
} else if (node.type === 'ConditionalExpression') {
visit(node.consequent)
visit(node.alternate)
} else if (node.type === 'LogicalExpression' || (node.type === 'BinaryExpression' && node.operator === '+')) {
if (node.left.type !== 'PrivateName') visit(node.left)
visit(node.right)
} else if (node.type === 'TemplateLiteral') {
for (const quasi of node.quasis) {
const value = quasi.value.cooked ?? quasi.value.raw
if (cjkPattern.test(value) && quasi.start != null && quasi.end != null) {
code.overwrite(quasi.start - 1, quasi.end - 1, `\${${helperName}(${toExpressionString(value)})}`)
}
changed = true
needsHelper = true
return `${prefix}${attributeName}${equals}${quote}${wrapped}${quote}`
}
if (!cjkPattern.test(value)) {
return fullMatch
}
changed = true
needsHelper = true
return `${prefix}:${attributeName}='${helperName}(${toExpressionString(value)})'`
},
)
return { code, changed, needsHelper }
}
function transformTextSegment(segment: string): TemplateTransformResult {
if (!segment) {
return { code: segment, changed: false, needsHelper: false }
}
let changed = false
let needsHelper = false
let cursor = 0
let code = ''
const interpolationPattern = /\{\{([\s\S]*?)\}\}/g
let match: RegExpExecArray | null
while ((match = interpolationPattern.exec(segment))) {
const staticText = segment.slice(cursor, match.index)
if (cjkPattern.test(staticText)) {
code += renderTranslatedText(staticText)
changed = true
needsHelper = true
} else {
code += staticText
for (const value of node.expressions) visit(value as Expression)
} else if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression' || node.type === 'TSNonNullExpression' || node.type === 'TypeCastExpression' || node.type === 'ParenthesizedExpression') {
visit(node.expression)
}
const expression = match[1]
const wrapped = wrapExpression(expression)
code += `{{ ${wrapped} }}`
if (wrapped !== expression) {
changed = true
needsHelper = true
}
cursor = match.index + match[0].length
}
const tail = segment.slice(cursor)
if (cjkPattern.test(tail)) {
code += renderTranslatedText(tail)
changed = true
needsHelper = true
} else {
code += tail
}
return changed ? { code, changed, needsHelper } : { code: segment, changed: false, needsHelper: false }
// Only result positions are display text. Conditions, lookup keys, and call
// arguments may be application data and must retain their original values.
visit(parsed)
return code.toString()
}
function closeTag(stack: TagStackEntry[], tagName: string): void {
const index = stack.findLastIndex(entry => entry.name === tagName)
if (index >= 0) {
stack.splice(index)
function hasVPre(node: ElementNode): boolean {
// Vue removes v-pre from its AST. Remove the parsed attributes from the
// opening source before checking the remaining directive, so quoted values
// such as title="v-pre" cannot accidentally disable translation.
const openingEnd = node.children[0]?.loc.start.offset ?? node.loc.end.offset
let cursor = node.loc.start.offset
let unparsed = ''
for (const prop of node.props) {
unparsed += node.loc.source.slice(cursor - node.loc.start.offset, prop.loc.start.offset - node.loc.start.offset)
cursor = prop.loc.end.offset
}
unparsed += node.loc.source.slice(cursor - node.loc.start.offset, openingEnd - node.loc.start.offset)
return /\sv-pre(?:[\s=>]|$)/.test(unparsed)
}
function isInsideSkippedTag(stack: TagStackEntry[]): boolean {
return stack.some(entry => entry.skip)
function shouldSkipElement(node: ElementNode): boolean {
if (skipTags.has(node.tag.toLowerCase()) || hasVPre(node)) return true
return node.props.some(prop => {
if (prop.type !== NodeTypes.ATTRIBUTE) return false
const name = prop.name.toLowerCase()
return name === 'data-i18n-skip' || name === 'contenteditable' || name === 'v-pre'
|| (name === 'translate' && prop.value?.content.toLowerCase() === 'no')
})
}
export function transformLegacyTemplateI18n(template: string): TemplateTransformResult {
let code = ''
let changed = false
const ast = parseTemplate(template)
const code = new MagicString(template)
let needsHelper = false
let cursor = 0
const stack: TagStackEntry[] = []
while (cursor < template.length) {
if (template.startsWith('<!--', cursor)) {
const end = template.indexOf('-->', cursor + 4)
const nextCursor = end >= 0 ? end + 3 : template.length
code += template.slice(cursor, nextCursor)
cursor = nextCursor
continue
}
if (template[cursor] !== '<' || !looksLikeTagStart(template, cursor)) {
const nextTag = template.indexOf('<', cursor + 1)
const nextCursor = nextTag >= 0 ? nextTag : template.length
const segment = template.slice(cursor, nextCursor)
if (isInsideSkippedTag(stack)) {
code += segment
} else {
const transformed = transformTextSegment(segment)
code += transformed.code
changed = changed || transformed.changed
needsHelper = needsHelper || transformed.needsHelper
const visit = (node: TemplateChildNode): void => {
if (node.type === NodeTypes.ELEMENT) {
if (shouldSkipElement(node)) {
if (hasVPre(node) && !node.props.some(prop => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'data-i18n-skip')) {
code.appendLeft(node.loc.start.offset + node.tag.length + 1, ' data-i18n-skip')
}
return
}
cursor = nextCursor
continue
}
for (const prop of node.props) {
if (prop.type === NodeTypes.ATTRIBUTE) {
if (!prop.value || !isTranslatableAttribute(node, prop.name) || !cjkPattern.test(prop.value.content)) continue
const expression = `${helperName}(${toExpressionString(prop.value.content)})`
code.overwrite(prop.loc.start.offset, prop.loc.end.offset, `:${prop.name}='${escapeAttribute(expression)}'`)
needsHelper = true
} else if (prop.name === 'bind' && prop.arg?.type === NodeTypes.SIMPLE_EXPRESSION && prop.arg.isStatic && prop.exp?.type === NodeTypes.SIMPLE_EXPRESSION && isTranslatableAttribute(node, prop.arg.content)) {
const expression = translateExpression(prop.exp.content)
if (expression !== prop.exp.content) {
code.overwrite(prop.loc.start.offset, prop.loc.end.offset, `${prop.rawName ?? `:${prop.arg.content}`}='${escapeAttribute(expression)}'`)
needsHelper = true
}
}
}
const tagEnd = findTagEnd(template, cursor)
if (tagEnd < 0) {
code += template.slice(cursor)
break
}
const rawTag = template.slice(cursor, tagEnd + 1)
const info = parseTagInfo(rawTag)
const shouldTransformAttributes = !!info && !info.closing && !isInsideSkippedTag(stack) && !info.skipSubtree
if (shouldTransformAttributes) {
const transformed = transformTagAttributes(rawTag)
code += transformed.code
changed = changed || transformed.changed
needsHelper = needsHelper || transformed.needsHelper
} else {
code += rawTag
}
if (info) {
if (info.closing) {
closeTag(stack, info.name)
} else if (!info.selfClosing) {
stack.push({ name: info.name, skip: info.skipSubtree })
node.children.forEach(visit)
} else if (node.type === NodeTypes.TEXT && cjkPattern.test(node.content)) {
code.overwrite(node.loc.start.offset, node.loc.end.offset, `{{ ${helperName}(${toExpressionString(node.content)}) }}`)
needsHelper = true
} else if (node.type === NodeTypes.INTERPOLATION && node.content.type === NodeTypes.SIMPLE_EXPRESSION) {
const expression = translateExpression(node.content.content)
if (expression !== node.content.content) {
code.overwrite(node.content.loc.start.offset, node.content.loc.end.offset, expression)
needsHelper = true
}
}
cursor = tagEnd + 1
}
return { code, changed, needsHelper }
ast.children.forEach(visit)
return { code: code.toString(), changed: code.hasChanged(), needsHelper }
}
function injectScriptSetupHelper(source: string): string {
if (source.includes(`legacyT: ${helperName}`)) {
return source
export function transformVueSource(source: string): TemplateTransformResult {
const { descriptor } = parseSfc(source)
const template = descriptor.template
if (!template || template.src || (template.lang && template.lang !== 'html')) {
return { code: source, changed: false, needsHelper: false }
}
const transformed = transformLegacyTemplateI18n(template.content)
if (!transformed.changed) return { code: source, changed: false, needsHelper: false }
const code = new MagicString(source)
code.overwrite(template.loc.start.offset, template.loc.end.offset, transformed.code)
const helperSource = `\nimport { useI18n as ${helperImportName} } from '@/i18n'\nconst { legacyT: ${helperName} } = ${helperImportName}()\n`
const scriptSetupMatch = source.match(/<script\s+setup(?:\s[^>]*)?>/)
if (scriptSetupMatch?.index !== undefined) {
const insertAt = scriptSetupMatch.index + scriptSetupMatch[0].length
return `${source.slice(0, insertAt)}${helperSource}${source.slice(insertAt)}`
if (transformed.needsHelper && !descriptor.scriptSetup?.content.includes(`legacyT: ${helperName}`)) {
if (descriptor.scriptSetup) {
code.appendLeft(descriptor.scriptSetup.loc.start.offset, helperSource)
} else {
const language = descriptor.script ? descriptor.script.lang : 'ts'
const languageAttribute = language ? ` lang="${language}"` : ''
code.append(`\n<script setup${languageAttribute}>${helperSource}</script>\n`)
}
}
return `${source}\n<script setup lang="ts">${helperSource}</script>\n`
}
function transformVueSource(source: string): TemplateTransformResult {
const templateMatch = source.match(/<template(?:\s[^>]*)?>([\s\S]*?)<\/template>/)
if (!templateMatch || templateMatch.index === undefined) {
return { code: source, changed: false, needsHelper: false }
}
const templateContent = templateMatch[1]
const transformed = transformLegacyTemplateI18n(templateContent)
if (!transformed.changed) {
return { code: source, changed: false, needsHelper: false }
}
const templateStart = templateMatch.index + templateMatch[0].indexOf(templateContent)
const templateEnd = templateStart + templateContent.length
const nextSource = `${source.slice(0, templateStart)}${transformed.code}${source.slice(templateEnd)}`
const code = transformed.needsHelper ? injectScriptSetupHelper(nextSource) : nextSource
return {
code,
changed: true,
needsHelper: transformed.needsHelper,
}
return { code: code.toString(), changed: true, needsHelper: transformed.needsHelper }
}
export function legacyTemplateI18nPlugin(): Plugin {
@@ -352,20 +180,10 @@ export function legacyTemplateI18nPlugin(): Plugin {
name: 'aether-legacy-template-i18n',
enforce: 'pre',
transform(source, id) {
const filename = id.split('?')[0]
if (!filename.endsWith('.vue')) {
return null
}
if (!id.endsWith('.vue')) return null
const transformed = transformVueSource(source)
if (!transformed.changed) {
return null
}
return {
code: transformed.code,
map: null,
}
return transformed.changed ? { code: transformed.code, map: null } : null
},
}
}
+471
View File
@@ -0,0 +1,471 @@
// Former fallback terms are matched only as complete labels.
export const legacyTermEnglishMessages: Record<string, string> = {
"未启用": "Disabled",
"允许退款": "Refunds allowed",
"关闭退款": "Refunds off",
"允许用户退款": "User refunds allowed",
"关闭用户退款": "User refunds off",
"确认": "Confirm",
"统一查看": "view in one place",
"健康状态": "health status",
"判断": "identify",
"具体": "specific",
"缓存命中率": "cache hit rate",
"成本缓存命中率": "cost cache hit rate",
"费用缓存命中率": "cost cache hit rate",
"请求命中率": "request hit rate",
"Token 命中率": "token hit rate",
"缓存读取费用": "cache read cost",
"读取费用": "read cost",
"命中": "hit",
"品牌名称右方": "right of the brand name",
"日志": "logs",
"本地": "local",
"表示": "means",
"未单独配置": "without separate configuration",
"跟随这里": "follow this setting",
"心跳": "heartbeat",
"非流式": "non-streaming",
"请求记录级别": "request record level",
"入库方式": "storage mode",
"详细程度": "detail level",
"基本信息": "basic information",
"含请求头": "includes request headers",
"完整请求响应": "full request and response",
"自动脱敏": "automatically redacted",
"脱敏": "redacted",
"逗号分隔": "comma-separated",
"这些请求头": "these request headers",
"脱敏处理": "redacted",
"分钟级图表": "minute-level chart",
"后端最少保留": "backend keeps at least",
"系统操作日志": "system operation logs",
"细粒度权限控制": "fine-grained permission control",
"权限控制": "permission control",
"用户认证": "user authentication",
"连接信息": "connection information",
"第三方": "third-party",
"登录/绑定账号": "sign-in/bind accounts",
"后台任务": "background tasks",
"用户通知": "user notifications",
"推送渠道": "push channels",
"微信推送": "WeChat push",
"服务启用状态": "service enabled state",
"服务等级": "service tier",
"服务": "service",
"模型名后缀": "model name suffix",
"推理参数": "inference parameters",
"完整数据": "full data",
"定期备份": "periodic backup",
"上传的文件": "uploaded files",
"查看和删除": "view and delete",
"具有": "with",
"能力": "capability",
"支付网关": "payment gateways",
"配置易支付": "configure EPay",
"余额接近不足": "balance is getting low",
"接近不足": "getting low",
"累计充值": "cumulative top-up",
"消费": "spending",
"兑换码充值": "redeem-code top-up",
"卡密": "redeem code",
"直接充值": "top up directly",
"充值余额": "top-up balance",
"变动": "change",
"Server 酱": "Server Chan",
"用于": "used for",
"稳定性": "stability",
"签到": "check-in",
"执行": "run",
"主动": "proactive",
"即将过期": "about to expire",
"动态调度": "dynamic scheduling",
"调度": "scheduling",
"构建信息": "build information",
"清理策略": "cleanup policy",
"邮件": "email",
"计费": "billing",
"费率": "rate",
"按顺序": "in order",
"同优先级": "same-priority",
"不同优先级": "different-priority",
"其他设备": "other devices",
"其他": "other",
"Refresh锁": "refresh lock",
"concurrent锁": "concurrency lock",
"运维": "operations",
"排障": "troubleshooting",
"视角": "perspective",
"缓存页": "cache page",
"亲和": "affinity",
"内存": "memory",
"存储": "storage",
"通道手续费率": "channel fee rate",
"手续费率": "fee rate",
"手续费": "fee",
"支付币种": "payment currency",
"币种": "currency",
"通道": "channel",
"商户密钥": "merchant key",
"商户证书": "merchant certificate",
"商户号": "merchant ID",
"支付模式": "payment mode",
"应用私钥": "app private key",
"公钥": "public key",
"证书序列号": "certificate serial number",
"扫码单": "QR-code order",
"扫码支付": "QR-code payment",
"收银台": "checkout",
"下单": "order creation",
"前台": "frontend",
"二维码": "QR code",
"开通": "enable",
"拉起": "launch",
"移动端": "mobile",
"桌面端": "desktop",
"微信浏览器": "WeChat browser",
"非微信浏览器": "non-WeChat browser",
"公众号": "official account",
"同主体": "same merchant entity",
"表单": "form",
"场景": "scenarios",
"真实请求统计": "real request statistics",
"端点可用率": "endpoint availability",
"模型可用率": "model availability",
"提供商可用率": "provider availability",
"平均TTFB": "average TTFB",
"平均首字": "average first byte",
"邮件等": "emails and other",
"右方": "right side",
"后台": "background",
"备份": "backup",
"非管理员": "non-admin",
"请求候选记录": "request candidate records",
"统计聚合": "statistics aggregates",
"同步生图": "synchronous image generation",
"外层 HTTP 状态": "outer HTTP status",
"外层": "outer",
"固定为": "fixed to",
"上游失败": "upstream failure",
"标准文本": "standard text",
"非流式接口": "non-streaming API",
"验证码邮件": "verification code email",
"隐式加密": "implicit encryption",
"无加密": "no encryption",
"邮箱验证": "email verification",
"后缀限制": "suffix restriction",
"后缀": "suffix",
"列出": "listed",
"均可": "can all",
"邮件主题": "email subject",
"收件人": "recipient",
"供应商": "provider",
"聊天消息": "chat messages",
"敏感信息": "sensitive information",
"替换为": "replace with",
"占位符": "placeholder",
"返回客户端": "returning to client",
"自动还原": "restore automatically",
"文件上传": "file upload",
"对象存储": "object storage",
"保留策略": "retention policy",
"推送服务": "push service",
"推送": "push",
"微信": "WeChat",
"备份配置": "backup configuration",
"映射预览": "mapping preview",
"活动热力图": "activity heatmap",
"请求间隔": "request interval",
"用户请求": "user requests",
"缓存命中": "cache hits",
"命中率": "hit rate",
"预估节省": "estimated savings",
"可退充值订单": "refundable top-up orders",
"自助退款": "self-service refund",
"线下打款": "offline transfer",
"原路退回": "original-route refund",
"退款申请": "refund request",
"钱包迁移": "wallet migration",
"按日汇总": "daily summary",
"管理令牌": "management token",
"已达上限": "limit reached",
"全权限": "full permissions",
"全权": "full access",
"只读": "read-only",
"禁止": "deny",
"用途说明": "usage notes",
"浅色": "Light",
"深色": "Dark",
"时区": "timezone",
"通知设置": "notification settings",
"使用提醒": "usage reminders",
"默认用户初始赠款(美元)": "Default user initial grant (USD)",
"默认速率限制 (请求/分钟)": "Default rate limit (requests/min)",
"弱密码 - 至少 6 个字符": "Weak password - at least 6 characters",
"中等密码 - 至少 8 位,含字母和数字": "Medium password - at least 8 characters with letters and numbers",
"强密码 - 至少 8 位,含大小写字母、数字和特殊字符": "Strong password - at least 8 characters with uppercase, lowercase, numbers, and symbols",
"生成key并填入.env": "Generate keys and write them to .env",
"自动执行数据库migration": "Runs database migrations automatically",
"GitHub 仓库 >": "GitHub repository >",
"OAuth授权登录": "OAuth authorization login",
"导入RefreshToken, 支持批量导入": "Import RefreshToken with batch import support",
"导入 RefreshToken, 支持批量导入": "Import RefreshToken with batch import support",
"支持批量导入": "batch import supported",
"格式要求": "format requirements",
"优先级:Key代理 > 提供商代理 > 全局代理": "Priority: key proxy > provider proxy > global proxy",
"优先级:": "Priority:",
"优先级:": "Priority:",
"关联全局模型": "Link global model",
"名称修正示例": "Name correction example",
"降/升级请求示例": "Downgrade/upgrade request example",
"传统密钥": "traditional key",
"用于 Google Cloud": "for Google Cloud",
"使用速率限制": "usage rate limit",
"同一个用户Key": "same user key",
"优先使用之前使用的提供商、Key响应请求": "prefer the previously used provider and key for the request",
"提高缓存命中率": "improve cache hit rate",
"全局代理": "global proxy",
"提供商代理": "provider proxy",
"Key代理": "key proxy",
"系统配置": "system configuration",
"提供商配置": "provider configuration",
"Key配置": "key configuration",
"清理记录": "cleanup records",
"详细记录": "detailed records",
"压缩记录": "compressed records",
"详细体": "detailed body",
"迁移": "migration",
"清体": "body cleanup",
"清头": "header cleanup",
"删记录": "delete records",
"删日志": "delete logs",
"删候选": "delete candidates",
"来源": "source",
"耗时": "duration",
"加载中": "loading",
"暂无": "no",
"设置": "settings",
"分析": "analysis",
"监控": "monitor",
"记录": "records",
"缓存": "cache",
"节点": "node",
"支付": "payment",
"充值": "top-up",
"订单": "order",
"健康": "health",
"成功率": "success rate",
"平均": "average",
"峰值": "peak",
"速率": "rate",
"限制": "limit",
"选择": "select",
"输入": "enter",
"留空": "leave empty",
"全局模型": "global model",
"统一模型规范": "unified model specification",
"协议聚合": "protocol aggregation",
"多端鉴权": "multi-client authentication",
"管控": "governance",
"全局并发": "global concurrency",
"智能调度": "intelligent scheduling",
"故障转移": "failover",
"原生直通": "native passthrough",
"多格式兼容接入": "multi-format compatible access",
"反代": "reverse proxy",
"持续使用": "continuous use",
"月卡额度": "monthly quota",
"不计入成本": "not counted as cost",
"倍率": "multiplier",
"流式首字超时时间": "streaming first-byte timeout",
"非流请求超时时间": "non-streaming request timeout",
"保持优先级": "keep priority",
"提供商设置": "provider settings",
"无视": "ignore",
"层级": "level",
"相同用户": "same user",
"之前处理过": "previously handled",
"最大化利用": "maximize use of",
"均匀分配流量": "evenly distribute traffic",
"随机性": "randomness",
"动态调整": "dynamic adjustment",
"遍历尝试": "iterate attempts",
"备用节点": "standby node",
"条件匹配": "condition matching",
"动作": "actions",
"动态修改": "dynamically modify",
"发往": "sent to",
"模型名称": "model name",
"平台标准名称": "platform standard name",
"统一模型名": "unified model name",
"真实需要": "actually required",
"正则表达式": "regular expression",
"正则过滤": "regex filtering",
"清洗": "cleanup",
"符合规则": "matching rules",
"官方接口": "official APIs",
"常见聚合平台": "common aggregation platforms",
"自动抓取": "automatically fetch",
"阈值": "threshold",
"触发报警": "trigger alerts",
"禁用策略": "disable policy",
"一键导出": "one-click export",
"部署实例": "deployment instances",
"迁移导入": "migration import",
"恶意使用": "malicious use",
"异常调用": "abnormal calls",
"高频报错": "frequent errors",
"永久锁定": "permanently lock",
"阻断攻击源头": "block attack source",
"吞吐": "throughput",
"时长": "duration",
"首字": "first byte",
"加载流量趋势中": "loading traffic trends",
"全局在线": "global online",
"拒绝": "rejected",
"独立 Key": "standalone key",
"分类": "category",
"列表": "list",
"用户套餐中心": "user billing center",
"查询": "search",
"策略分组": "policy groups",
"策略": "policy",
"维度": "dimension",
"默认策略": "default policy",
"回溯时间": "lookback time",
"可用率": "availability",
"平均耗时": "average duration",
"平均速度": "average speed",
"仅展示": "only show",
"点击详情查看": "click details to view",
"关联的": "linked",
"系统级别": "system-level",
"副标题": "subtitle",
"导航栏": "navigation bar",
"登录页": "sign-in page",
"指南页面": "guide pages",
"全站显示": "site-wide display",
"显示在": "shown in",
"邮件发送服务": "email sending service",
"注册邮箱限制": "registration email limit",
"服务器地址": "server address",
"邮件服务器地址": "mail server address",
"端口": "port",
"实时性能监控": "realtime performance monitoring",
"历史延迟趋势": "historical latency trends",
"实时运行状态": "realtime runtime status",
"实时与历史性能数据": "realtime and historical performance data",
"聚合系统健康": "aggregate system health",
"并发保护": "concurrency protection",
"代理通道": "proxy channels",
"降级切换": "fallback switching",
"网关指标": "gateway metrics",
"暂不可达": "currently unreachable",
"未知": "unknown",
"系统功能模块": "system feature modules",
"启用状态": "enablement status",
"搜索模块名称或描述": "search module name or description",
"内置工具": "built-in tools",
"通知服务": "notification service",
"邮件模板": "email templates",
"发送设置": "sending settings",
"登录/绑定": "sign-in/bind",
"添加配置": "add configuration",
"数据": "data",
"按API格式分析": "by-API-format analysis",
"格式": "format",
"头像 URL": "avatar URL",
"个人简介": "bio",
"默认应用": "apply by default",
"单独配置": "separate configuration",
"跟随此设置": "follow this setting",
"程序化访问管理 API 的令牌": "tokens for programmatic access to management APIs",
"创建新令牌": "create new token",
"安全": "security",
"可信任": "trusted",
"支持 CIDR 格式": "CIDR supported",
"和通知": "and notifications",
"已过期": "expired",
"阶梯": "tier",
"认证方式": "authentication methods",
"平滑接入": "smooth onboarding",
"对外暴露": "expose externally",
"对内映射": "map internally",
"内部模型名": "internal model name",
"模型名": "model name",
"变体": "variants",
"亲和性路由": "affinity routing",
"跨平台": "cross-platform",
"兼容格式": "compatible format",
"原生透传": "native passthrough",
"生态": "ecosystem",
"入口": "entry",
"一般": "usually",
"章节": "section",
"按周期": "by cycle",
"首次": "first",
"重试次数": "retry count",
"总超时时间": "total timeout",
"超时时间": "timeout",
"当前优先级": "current priority",
"规则": "rules",
"发送": "send",
"匹配": "match",
"一类": "one class of",
"标准模型列表": "standard model list",
"模型列表": "model list",
"低于": "below",
"各大": "major",
"金额下单": "order amount",
"实际收款金额": "actual received amount",
"最低充值金额": "minimum top-up amount",
"回调地址": "callback URL",
"后端": "backend",
"访问地址": "access URL",
"加密方式": "encryption method",
"常用端口": "common ports",
"应用专用密码": "app-specific password",
"发件人": "sender",
"邮箱地址": "email address",
"邮件服务": "email service",
"通知项": "notification items",
"模板": "templates",
"黑白名单": "blacklist/whitelist",
"控制系统访问": "control system access",
"追踪安全事件": "track security events",
"变更记录": "change records",
"拖拽卡片调整顺序": "drag cards to reorder",
"细粒度": "fine-grained",
"配置和参数": "configuration and parameters",
"清除系统数据": "clear system data",
"用户数据": "user data",
"一体化备份": "integrated backup",
"钱包快照": "wallet snapshots",
"品牌名称": "brand name",
"品牌名": "brand name",
"邮件中": "emails",
"兑换": "redeem",
"赠款余额": "gift balance",
"可退款余额": "refundable balance",
"每日额度套餐": "daily quota plan",
"未配置": "not configured",
"暂不可修改": "temporarily cannot be changed",
"退出其他设备": "sign out other devices",
"筛选用户": "filter user",
"筛选提供商": "filter provider",
"筛选类型": "filter type",
"总耗时": "total duration",
"输出速度": "output speed",
"最近24小时": "last 24 hours",
"自动刷新": "auto refresh",
"隐藏未知模型或提供商的请求": "hide requests with unknown model or provider",
"开启自动刷新": "turn on auto refresh",
"映射缓存": "mapping cache",
"解析缓存": "resolution cache",
"个键": "keys",
"次数": "count",
"错误样本": "error samples",
"数据流": "data flow",
"百分位": "percentiles",
"无上游样本": "no upstream samples",
}
File diff suppressed because it is too large Load Diff
+92 -794
View File
@@ -1,3 +1,8 @@
import { legacyUiEnglishMessages } from './legacy-ui-messages'
import { legacyTermEnglishMessages } from './legacy-term-messages'
import { legacyAdminEnglishMessages } from './legacy-admin-messages'
import { legacyGuideEnglishMessages } from './legacy-guide-messages'
export const messages = {
'zh-CN': {
'common.loading': '加载中...',
@@ -6,12 +11,41 @@ export const messages = {
'common.copy': '复制',
'common.copied': '已复制',
'common.cancel': '取消',
'common.configure': '配置',
'modules.bark.deviceKeyHint': '使用 Bark App 推送地址(例如 https://api.day.app/xxxx)中的 xxxx 部分。',
'common.githubRepository': 'GitHub 仓库',
'common.settings': '个人设置',
'common.logout': '退出登录',
'common.language': '语言',
'common.chinese': '简体中文',
'common.english': 'English',
'common.searchPlaceholder': '输入关键词搜索...',
'common.noSearchResults': '未找到匹配项',
'common.sort': '排序',
'common.filter': '筛选',
'common.openMenu': '打开导航菜单',
'common.closeMenu': '关闭导航菜单',
'pagination.range': '显示 {start}-{end} 条,共 {total} 条',
'pagination.pageSize': '{size} 条/页',
'pagination.goToPage': '跳至',
'pagination.pageLabel': '页',
'pagination.pageNumber': '第 {page} 页',
'pagination.pageSizeLabel': '每页条数',
'pagination.jumpToPage': '跳转页码',
'chart.actualCost': '实际成本',
'chart.forecastCost': '预测成本',
'chart.intervalAxis': '间隔 (分钟)',
'chart.intervalTooltip': '间隔: {value} 分钟',
'chart.crosshairValue': 'Y = {value} 分钟',
'chart.pointsBelow': '点在横线以下',
'chart.total': '总计',
'chart.unknown': '未知',
'heatmap.requests': '{count} 次请求',
'heatmap.cost': '成本 {value}',
'heatmap.actualCost': '实际成本 {value}',
'heatmap.less': '少',
'heatmap.more': '多',
'heatmap.empty': '暂无活跃数据',
'auth.expired': '认证已过期,请重新登录',
'auth.relogin': '重新登录',
'auth.role.admin': '管理员',
@@ -423,12 +457,41 @@ export const messages = {
'common.copy': 'Copy',
'common.copied': 'Copied',
'common.cancel': 'Cancel',
'common.configure': 'Configure',
'modules.bark.deviceKeyHint': 'Use the xxxx part of your Bark app push URL, for example https://api.day.app/xxxx.',
'common.githubRepository': 'GitHub repository',
'common.settings': 'Profile settings',
'common.logout': 'Log out',
'common.language': 'Language',
'common.chinese': 'Simplified Chinese',
'common.english': 'English',
'common.searchPlaceholder': 'Search...',
'common.noSearchResults': 'No results found',
'common.sort': 'Sort',
'common.filter': 'Filter',
'common.openMenu': 'Open navigation menu',
'common.closeMenu': 'Close navigation menu',
'pagination.range': 'Showing {start}-{end} of {total} items',
'pagination.pageSize': '{size} / page',
'pagination.goToPage': 'Go to',
'pagination.pageLabel': 'page',
'pagination.pageNumber': 'Page {page}',
'pagination.pageSizeLabel': 'Items per page',
'pagination.jumpToPage': 'Go to page',
'chart.actualCost': 'Actual cost',
'chart.forecastCost': 'Forecast cost',
'chart.intervalAxis': 'Interval (minutes)',
'chart.intervalTooltip': 'Interval: {value} minutes',
'chart.crosshairValue': 'Y = {value} minutes',
'chart.pointsBelow': 'points below the line',
'chart.total': 'Total',
'chart.unknown': 'Unknown',
'heatmap.requests': '{count} requests',
'heatmap.cost': 'Cost {value}',
'heatmap.actualCost': 'Actual cost {value}',
'heatmap.less': 'Less',
'heatmap.more': 'More',
'heatmap.empty': 'No activity data',
'auth.expired': 'Authentication expired. Please sign in again.',
'auth.relogin': 'Sign in again',
'auth.role.admin': 'Admin',
@@ -836,6 +899,15 @@ export const messages = {
} as const
const legacyExactEnglishMessages: Record<string, string> = {
...legacyTermEnglishMessages,
...Object.fromEntries(
Object.entries(messages['zh-CN'])
.filter(([, source]) => !source.includes('{'))
.map(([key, source]) => [source, messages['en-US'][key as keyof typeof messages['en-US']]]),
),
...legacyUiEnglishMessages,
...legacyAdminEnglishMessages,
...legacyGuideEnglishMessages,
'关闭': 'Close',
'取消': 'Cancel',
'确定': 'Confirm',
@@ -2859,7 +2931,19 @@ const legacyPhraseEnglishMessages: Array<[string, string]> = [
['暂无可用号池', 'No available pools'],
]
const legacyStaticEnglishMessages: Readonly<Record<string, string>> = {
...Object.fromEntries(legacyPhraseEnglishMessages),
...legacyExactEnglishMessages,
}
const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
[/^(\d+) $/u, match => `${match[1]} ${match[1] === '1' ? 'dimension' : 'dimensions'}`],
[/^(\d+)([01]?\d|2[0-3])$/u, match => `${match[1]}d ${match[2]}h`],
[/^(\d+)\s+([01]?\d|2[0-3]):([0-5]\d):([0-5]\d)$/u, match => `${match[1]}d ${match[2]}:${match[3]}:${match[4]}`],
[/^ (\d[\d.,]*%)$/u, match => `Success rate ${match[1]}`],
[/^ ([\d.,]+[KMB]?) \/ ([\d.,]+[KMB]?)$/u, match => `Input ${match[1]} / Output ${match[2]}`],
[/^ (\$[\d.,]+) \((\d[\d.,]*%)\)$/u, match => `Saved ${match[1]} (${match[2]})`],
[/^ (\d[\d,]*)$/u, match => `Total users ${match[1]}`],
[/^(.+) · (.+)$/u, match => `${match[1]} requests · ${match[2]}`],
[/^ (.+) Key$/u, match => `No ${match[1]} format keys`],
[/^ (.+)$/u, match => `Session remaining ${match[1]}`],
@@ -2869,7 +2953,7 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^ (.+)$/u, match => `Published at ${match[1]}`],
[/^ (.+) $/u, match => `${match[1]} enabled`],
[/^ (.+) $/u, match => `${match[1]} rows affected`],
[/^(.+)$/u, match => `${match[1]}/`],
[/^(1[0-2]|[1-9])$/u, match => new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2020, Number(match[1]) - 1, 1)))],
[/^(.+)$/u, match => `Total available: ${match[1]}`],
[/^(.+)$/u, match => `Balance: ${match[1]}`],
[/^(.+) $/u, match => `${translateLegacyText(match[1], 'en-US')}s`],
@@ -2878,14 +2962,13 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^(.+) $/u, match => `${translateLegacyText(match[1], 'en-US')} d`],
[/^(.+) $/u, match => `Affected users: ${match[1]}`],
[/^ (.+) $/u, match => `and ${match[1]} users`],
[/^(.+)(.+)$/u, match => `Confirm ${translateLegacyByTokens(match[1])} (${match[2]})`],
[/^(|||)(.+)$/u, match => `Confirm ${translateLegacyText(match[1], 'en-US').toLowerCase()} (${match[2]})`],
[/^ (.+) (.+) $/u, match => `Succeeded ${match[1]}, failed ${match[2]}`],
[/^ (.+) (.+) $/u, match => `Batch action complete: succeeded ${match[1]}, failed ${match[2]}`],
[/^ (.+) (.+) (.+) $/u, match => `Matched ${match[1]} items, current page ${match[2]} items, selected ${match[3]} items`],
[/^ (.+) $/u, match => `Selected ${match[1]} items`],
[/^线 (.+) $/u, match => `Signed out ${match[1]} devices`],
[/^(.+)$/u, match => `User ${translateLegacyByTokens(match[1]).toLowerCase()}d`],
[/^(.+)$/u, match => `Failed to ${translateLegacyByTokens(match[1]).toLowerCase()} user`],
[/^(||||)$/u, match => `Failed to ${translateLegacyText(match[1], 'en-US').toLowerCase()} user`],
[/^(.+) $/u, match => `${match[1]} users`],
[/^ (.+) $/u, match => `${match[1]} format errors`],
[/^ (.+) $/u, match => `${match[1]} more errors hidden`],
@@ -2915,7 +2998,7 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^ (.+) (.+)(.+) $/u, match => `Wrote upgrade target ${match[2]} to ${match[1]} nodes; ${match[3]} nodes did not need changes`],
[/^ tunnel (.+)$/u, match => `No tunnel nodes need changes. Target version remains ${match[1]}`],
[/^ (.+) (.+): (.+)$/u, match => `Entry ${match[1]} ${match[2]}: ${translateLegacyText(match[3], 'en-US')}`],
[/^(.+) URL $/u, match => `${translateLegacyByTokens(match[1])} contains invalid URL encoding`],
[/^(.+) URL $/u, match => `${translateLegacyText(match[1], 'en-US')} contains invalid URL encoding`],
[/^(.+) $/u, match => `${match[1]} points`],
[/^(.+)\/(.+) $/u, match => `${match[1]}/${match[2]} samples`],
[/^ (.+)$/u, match => `Peak ${match[1]}`],
@@ -2930,755 +3013,6 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
[/^(.+) tokens $/u, match => `${match[1]} tokens hit`],
]
const legacyFallbackTokens: Array<[string, string]> = [
...legacyPhraseEnglishMessages,
['未启用', 'Disabled'],
['允许退款', 'Refunds allowed'],
['关闭退款', 'Refunds off'],
['允许用户退款', 'User refunds allowed'],
['关闭用户退款', 'User refunds off'],
['已保存', 'Saved'],
['确认', 'Confirm'],
['统一查看', 'view in one place'],
['健康状态', 'health status'],
['概览', 'overview'],
['判断', 'identify'],
['风险', 'risk'],
['具体', 'specific'],
['缓存命中率', 'cache hit rate'],
['成本缓存命中率', 'cost cache hit rate'],
['费用缓存命中率', 'cost cache hit rate'],
['请求命中率', 'request hit rate'],
['Token 命中率', 'token hit rate'],
['缓存读取费用', 'cache read cost'],
['缓存读取', 'cache read'],
['读取费用', 'read cost'],
['命中', 'hit'],
['品牌名称右方', 'right of the brand name'],
['日志', 'logs'],
['本地', 'local'],
['表示', 'means'],
['未单独配置', 'without separate configuration'],
['跟随这里', 'follow this setting'],
['心跳', 'heartbeat'],
['非流式', 'non-streaming'],
['请求记录级别', 'request record level'],
['入库方式', 'storage mode'],
['详细程度', 'detail level'],
['基本信息', 'basic information'],
['含请求头', 'includes request headers'],
['完整请求响应', 'full request and response'],
['自动脱敏', 'automatically redacted'],
['脱敏', 'redacted'],
['逗号分隔', 'comma-separated'],
['这些请求头', 'these request headers'],
['脱敏处理', 'redacted'],
['分钟级图表', 'minute-level chart'],
['后端最少保留', 'backend keeps at least'],
['系统操作日志', 'system operation logs'],
['细粒度权限控制', 'fine-grained permission control'],
['权限控制', 'permission control'],
['用户认证', 'user authentication'],
['连接信息', 'connection information'],
['第三方', 'third-party'],
['登录/绑定账号', 'sign-in/bind accounts'],
['后台任务', 'background tasks'],
['用户通知', 'user notifications'],
['推送渠道', 'push channels'],
['微信推送', 'WeChat push'],
['服务启用状态', 'service enabled state'],
['服务等级', 'service tier'],
['服务', 'service'],
['模型名后缀', 'model name suffix'],
['推理参数', 'inference parameters'],
['完整数据', 'full data'],
['定期备份', 'periodic backup'],
['上传的文件', 'uploaded files'],
['查看和删除', 'view and delete'],
['具有', 'with'],
['能力', 'capability'],
['支付网关', 'payment gateways'],
['配置易支付', 'configure EPay'],
['支付宝官方', 'official Alipay'],
['微信支付官方', 'official WeChat Pay'],
['余额接近不足', 'balance is getting low'],
['接近不足', 'getting low'],
['公告通知', 'announcement notifications'],
['系统公告', 'system announcements'],
['注册时间', 'registered at'],
['最后登录', 'last sign-in'],
['总余额', 'total balance'],
['累计消费占比', 'cumulative spending ratio'],
['累计消费', 'cumulative spending'],
['累计充值', 'cumulative top-up'],
['消费', 'spending'],
['兑换码充值', 'redeem-code top-up'],
['卡密', 'redeem code'],
['直接充值', 'top up directly'],
['充值余额', 'top-up balance'],
['发起充值', 'start top-up'],
['申请退款', 'request refund'],
['退款模式', 'refund mode'],
['变动', 'change'],
['余额变化', 'balance changes'],
['说明', 'description'],
['Server 酱', 'Server Chan'],
['酱', 'Chan'],
['用于', 'used for'],
['稳定性', 'stability'],
['系统', 'system'],
['签到', 'check-in'],
['执行', 'run'],
['主动', 'proactive'],
['即将过期', 'about to expire'],
['动态调度', 'dynamic scheduling'],
['调度', 'scheduling'],
['构建信息', 'build information'],
['清理策略', 'cleanup policy'],
['服务level', 'service level'],
['邮件', 'email'],
['预览', 'Preview'],
['注册', 'registration'],
['计费', 'billing'],
['费率', 'rate'],
['仪表盘', 'dashboard'],
['按顺序', 'in order'],
['同优先级', 'same-priority'],
['不同优先级', 'different-priority'],
['其他设备', 'other devices'],
['其他', 'other'],
['Refresh锁', 'refresh lock'],
['concurrent锁', 'concurrency lock'],
['锁', 'lock'],
['运维', 'operations'],
['排障', 'troubleshooting'],
['视角', 'perspective'],
['缓存页', 'cache page'],
['亲和性', 'affinity'],
['亲和', 'affinity'],
['内存', 'memory'],
['存储', 'storage'],
['通道手续费率', 'channel fee rate'],
['手续费率', 'fee rate'],
['手续费', 'fee'],
['支付币种', 'payment currency'],
['币种', 'currency'],
['通道值', 'channel value'],
['通道', 'channel'],
['商户密钥', 'merchant key'],
['商户证书', 'merchant certificate'],
['商户号', 'merchant ID'],
['商户', 'merchant'],
['支付模式', 'payment mode'],
['应用私钥', 'app private key'],
['公钥', 'public key'],
['证书序列号', 'certificate serial number'],
['扫码单', 'QR-code order'],
['扫码支付', 'QR-code payment'],
['收银台', 'checkout'],
['下单', 'order creation'],
['前台', 'frontend'],
['二维码', 'QR code'],
['开通', 'enable'],
['拉起', 'launch'],
['未开通', 'not enabled'],
['移动端', 'mobile'],
['桌面端', 'desktop'],
['微信浏览器', 'WeChat browser'],
['非微信浏览器', 'non-WeChat browser'],
['公众号', 'official account'],
['同主体', 'same merchant entity'],
['表单', 'form'],
['场景', 'scenarios'],
['真实请求统计', 'real request statistics'],
['端点可用率', 'endpoint availability'],
['模型可用率', 'model availability'],
['提供商可用率', 'provider availability'],
['平均TTFB', 'average TTFB'],
['平均首字', 'average first byte'],
['邮件等', 'emails and other'],
['右方', 'right side'],
['后台', 'background'],
['备份', 'backup'],
['完整备份', 'full backup'],
['非管理员', 'non-admin'],
['请求候选记录', 'request candidate records'],
['统计聚合', 'statistics aggregates'],
['同步生图', 'synchronous image generation'],
['外层 HTTP 状态', 'outer HTTP status'],
['外层', 'outer'],
['固定为', 'fixed to'],
['上游失败', 'upstream failure'],
['标准文本', 'standard text'],
['非流式接口', 'non-streaming API'],
['响应体', 'response body'],
['验证码邮件', 'verification code email'],
['隐式加密', 'implicit encryption'],
['无加密', 'no encryption'],
['邮箱验证', 'email verification'],
['后缀限制', 'suffix restriction'],
['后缀', 'suffix'],
['列出', 'listed'],
['均可', 'can all'],
['邮件主题', 'email subject'],
['收件人', 'recipient'],
['邮件预览', 'email preview'],
['供应商', 'provider'],
['聊天消息', 'chat messages'],
['敏感信息', 'sensitive information'],
['替换为', 'replace with'],
['占位符', 'placeholder'],
['返回客户端', 'returning to client'],
['自动还原', 'restore automatically'],
['服务等级', 'service tier'],
['上传文件', 'upload files'],
['文件上传', 'file upload'],
['对象存储', 'object storage'],
['保留策略', 'retention policy'],
['推送服务', 'push service'],
['推送', 'push'],
['微信', 'WeChat'],
['备份配置', 'backup configuration'],
['映射预览', 'mapping preview'],
['活动热力图', 'activity heatmap'],
['请求间隔', 'request interval'],
['用户请求', 'user requests'],
['缓存命中', 'cache hits'],
['命中率', 'hit rate'],
['预估节省', 'estimated savings'],
['可退充值订单', 'refundable top-up orders'],
['自助退款', 'self-service refund'],
['线下打款', 'offline transfer'],
['原路退回', 'original-route refund'],
['退款申请', 'refund request'],
['钱包迁移', 'wallet migration'],
['按日汇总', 'daily summary'],
['访问令牌', 'access token'],
['管理令牌', 'management token'],
['令牌', 'token'],
['已创建', 'created'],
['已达上限', 'limit reached'],
['最后 IP', 'last IP'],
['最后使用', 'last used'],
['从未使用', 'never used'],
['旧版全权限', 'legacy full permissions'],
['全权限', 'full permissions'],
['全权', 'full access'],
['只读', 'read-only'],
['禁止', 'deny'],
['用途说明', 'usage notes'],
['浅色', 'Light'],
['深色', 'Dark'],
['跟随系统', 'System'],
['时区', 'timezone'],
['通知设置', 'notification settings'],
['使用提醒', 'usage reminders'],
['默认用户初始赠款(美元)', 'Default user initial grant (USD)'],
['默认速率限制 (请求/分钟)', 'Default rate limit (requests/min)'],
['弱密码 - 至少 6 个字符', 'Weak password - at least 6 characters'],
['中等密码 - 至少 8 位,含字母和数字', 'Medium password - at least 8 characters with letters and numbers'],
['强密码 - 至少 8 位,含大小写字母、数字和特殊字符', 'Strong password - at least 8 characters with uppercase, lowercase, numbers, and symbols'],
['Docker 预构建镜像', 'Docker prebuilt image'],
['预构建镜像', 'Prebuilt image'],
['克隆代码', 'Clone repository'],
['启动依赖', 'Start dependencies'],
['安装前端依赖', 'Install frontend dependencies'],
['启动开发服务', 'Start development services'],
['生成密钥并填入 .env', 'Generate keys and write them to .env'],
['生成密钥', 'Generate keys'],
['生成key并填入.env', 'Generate keys and write them to .env'],
['自动执行数据库迁移', 'Runs database migrations automatically'],
['自动执行数据库migration', 'Runs database migrations automatically'],
['可选,make dev 会自动启动', 'Optional; make dev starts this automatically'],
['首次本地开发', 'First local development setup'],
['配置流程', 'Configuration flow'],
['模型映射', 'Model mapping'],
['代理节点', 'Proxy node'],
['代理配置', 'Proxy config'],
['反向代理', 'Reverse proxy'],
['多级代理', 'Multi-level proxy'],
['GitHub 仓库 >', 'GitHub repository >'],
['OAuth授权登录', 'OAuth authorization login'],
['导入RefreshToken, 支持批量导入', 'Import RefreshToken with batch import support'],
['导入 RefreshToken, 支持批量导入', 'Import RefreshToken with batch import support'],
['支持批量导入', 'batch import supported'],
['格式要求', 'format requirements'],
['优先级:Key代理 > 提供商代理 > 全局代理', 'Priority: key proxy > provider proxy > global proxy'],
['优先级:Key代理 > 提供商代理 > 全局代理', 'Priority: key proxy > provider proxy > global proxy'],
['优先级:', 'Priority:'],
['优先级:', 'Priority:'],
['创建统一模型', 'Create unified model'],
['添加提供商', 'Add provider'],
['添加端点', 'Add endpoint'],
['添加密钥', 'Add key'],
['关联全局模型', 'Link global model'],
['名称修正示例', 'Name correction example'],
['降/升级请求示例', 'Downgrade/upgrade request example'],
['官方标准名称为', 'Official standard name is'],
['实际提供商叫', 'the actual provider calls it'],
['实际请求提供商用', 'actual provider request uses'],
['传统密钥', 'traditional key'],
['用于 Google Cloud', 'for Google Cloud'],
['使用速率限制', 'usage rate limit'],
['同一个用户Key', 'same user key'],
['优先使用之前使用的提供商、Key响应请求', 'prefer the previously used provider and key for the request'],
['提高缓存命中率', 'improve cache hit rate'],
['全局代理', 'global proxy'],
['提供商代理', 'provider proxy'],
['Key代理', 'key proxy'],
['系统配置', 'system configuration'],
['提供商配置', 'provider configuration'],
['Key配置', 'key configuration'],
['清理记录', 'cleanup records'],
['详细记录', 'detailed records'],
['压缩记录', 'compressed records'],
['审计日志', 'audit logs'],
['候选记录', 'candidate records'],
['请求体', 'request body'],
['请求头', 'request headers'],
['详细体', 'detailed body'],
['迁移', 'migration'],
['清体', 'body cleanup'],
['清头', 'header cleanup'],
['删记录', 'delete records'],
['删日志', 'delete logs'],
['删候选', 'delete candidates'],
['执行中', 'running'],
['完成', 'complete'],
['失败', 'failed'],
['手动', 'manual'],
['自动', 'automatic'],
['时间', 'time'],
['类型', 'type'],
['来源', 'source'],
['状态', 'status'],
['结果', 'result'],
['耗时', 'duration'],
['刷新', 'refresh'],
['保存中', 'saving'],
['保存', 'save'],
['加载中', 'loading'],
['暂无', 'no'],
['当前', 'current'],
['设置', 'settings'],
['配置', 'configuration'],
['管理', 'management'],
['统计', 'statistics'],
['分析', 'analysis'],
['监控', 'monitor'],
['记录', 'records'],
['用户', 'user'],
['模型', 'model'],
['提供商', 'provider'],
['密钥', 'key'],
['端点', 'endpoint'],
['请求', 'request'],
['响应', 'response'],
['缓存', 'cache'],
['代理', 'proxy'],
['节点', 'node'],
['钱包', 'wallet'],
['套餐', 'plan'],
['余额', 'balance'],
['成本', 'cost'],
['金额', 'amount'],
['支付', 'payment'],
['充值', 'top-up'],
['订单', 'order'],
['分组', 'group'],
['角色', 'role'],
['权限', 'permission'],
['限额', 'quota'],
['用量', 'usage'],
['健康', 'health'],
['延迟', 'latency'],
['失败率', 'failure rate'],
['成功率', 'success rate'],
['平均', 'average'],
['峰值', 'peak'],
['并发', 'concurrency'],
['速率', 'rate'],
['限制', 'limit'],
['启用', 'enable'],
['停用', 'disable'],
['删除', 'delete'],
['编辑', 'edit'],
['创建', 'create'],
['添加', 'add'],
['更新', 'update'],
['导入', 'import'],
['导出', 'export'],
['清空', 'clear'],
['选择', 'select'],
['输入', 'enter'],
['留空', 'leave empty'],
['默认', 'default'],
['全部', 'all'],
['可用', 'available'],
['不可用', 'unavailable'],
['在线', 'online'],
['离线', 'offline'],
['活跃', 'active'],
['已', 'already'],
['未', 'not'],
['中', 'in progress'],
['前', 'before'],
['后', 'after'],
['全局模型', 'global model'],
['系统架构', 'system architecture'],
['统一模型规范', 'unified model specification'],
['协议聚合', 'protocol aggregation'],
['多端鉴权', 'multi-client authentication'],
['管控', 'governance'],
['全局并发', 'global concurrency'],
['智能调度', 'intelligent scheduling'],
['故障转移', 'failover'],
['原生直通', 'native passthrough'],
['多格式兼容接入', 'multi-format compatible access'],
['反代', 'reverse proxy'],
['持续使用', 'continuous use'],
['月卡额度', 'monthly quota'],
['不计入成本', 'not counted as cost'],
['倍率', 'multiplier'],
['流式首字超时时间', 'streaming first-byte timeout'],
['非流请求超时时间', 'non-streaming request timeout'],
['保持优先级', 'keep priority'],
['提供商设置', 'provider settings'],
['无视', 'ignore'],
['层级', 'level'],
['相同用户', 'same user'],
['之前处理过', 'previously handled'],
['最大化利用', 'maximize use of'],
['均匀分配流量', 'evenly distribute traffic'],
['随机性', 'randomness'],
['动态调整', 'dynamic adjustment'],
['遍历尝试', 'iterate attempts'],
['备用节点', 'standby node'],
['条件匹配', 'condition matching'],
['动作', 'actions'],
['动态修改', 'dynamically modify'],
['发往', 'sent to'],
['模型名称', 'model name'],
['平台标准名称', 'platform standard name'],
['统一模型名', 'unified model name'],
['真实需要', 'actually required'],
['正则表达式', 'regular expression'],
['批量授权', 'bulk authorization'],
['正则过滤', 'regex filtering'],
['清洗', 'cleanup'],
['符合规则', 'matching rules'],
['官方接口', 'official APIs'],
['常见聚合平台', 'common aggregation platforms'],
['自动抓取', 'automatically fetch'],
['剩余额度', 'remaining quota'],
['阈值', 'threshold'],
['触发报警', 'trigger alerts'],
['禁用策略', 'disable policy'],
['一键导出', 'one-click export'],
['部署实例', 'deployment instances'],
['迁移导入', 'migration import'],
['恶意使用', 'malicious use'],
['异常调用', 'abnormal calls'],
['高频报错', 'frequent errors'],
['永久锁定', 'permanently lock'],
['临时', 'temporarily'],
['阻断攻击源头', 'block attack source'],
['吞吐', 'throughput'],
['时长', 'duration'],
['首字', 'first byte'],
['加载流量趋势中', 'loading traffic trends'],
['全局在线', 'global online'],
['拒绝', 'rejected'],
['独立 Key', 'standalone key'],
['批量管理', 'batch management'],
['分类', 'category'],
['列表', 'list'],
['用户套餐中心', 'user billing center'],
['查询', 'search'],
['策略分组', 'policy groups'],
['策略', 'policy'],
['维度', 'dimension'],
['默认策略', 'default policy'],
['更新时间', 'updated at'],
['回溯时间', 'lookback time'],
['真实请求统计', 'real request statistics'],
['可用率', 'availability'],
['平均耗时', 'average duration'],
['平均速度', 'average speed'],
['仅展示', 'only show'],
['点击详情查看', 'click details to view'],
['关联的', 'linked'],
['系统级别', 'system-level'],
['副标题', 'subtitle'],
['导航栏', 'navigation bar'],
['登录页', 'sign-in page'],
['指南页面', 'guide pages'],
['全站显示', 'site-wide display'],
['显示在', 'shown in'],
['邮件发送服务', 'email sending service'],
['注册邮箱限制', 'registration email limit'],
['验证码邮件', 'verification code email'],
['服务器地址', 'server address'],
['邮件服务器地址', 'mail server address'],
['端口', 'port'],
['实时性能监控', 'realtime performance monitoring'],
['历史延迟趋势', 'historical latency trends'],
['实时运行状态', 'realtime runtime status'],
['实时与历史性能数据', 'realtime and historical performance data'],
['聚合系统健康', 'aggregate system health'],
['并发保护', 'concurrency protection'],
['代理通道', 'proxy channels'],
['降级切换', 'fallback switching'],
['网关指标', 'gateway metrics'],
['暂不可达', 'currently unreachable'],
['未知', 'unknown'],
['系统功能模块', 'system feature modules'],
['启用状态', 'enablement status'],
['搜索模块名称或描述', 'search module name or description'],
['内置工具', 'built-in tools'],
['通知服务', 'notification service'],
['邮件模板', 'email templates'],
['发送设置', 'sending settings'],
['登录/绑定', 'sign-in/bind'],
['添加配置', 'add configuration'],
['命中率', 'hit rate'],
['数据', 'data'],
['按API格式分析', 'by-API-format analysis'],
['格式', 'format'],
['平均响应', 'average response'],
['成功率', 'success rate'],
['个人设置', 'profile settings'],
['基本信息', 'basic information'],
['头像 URL', 'avatar URL'],
['个人简介', 'bio'],
['默认应用', 'apply by default'],
['账户', 'account'],
['单独配置', 'separate configuration'],
['跟随此设置', 'follow this setting'],
['访问令牌', 'access tokens'],
['程序化访问管理 API 的令牌', 'tokens for programmatic access to management APIs'],
['创建新令牌', 'create new token'],
['安全', 'security'],
['可信任', 'trusted'],
['支持 CIDR 格式', 'CIDR supported'],
['和通知', 'and notifications'],
['已过期', 'expired'],
['阶梯', 'tier'],
['认证方式', 'authentication methods'],
['平滑接入', 'smooth onboarding'],
['对外暴露', 'expose externally'],
['对内映射', 'map internally'],
['内部模型名', 'internal model name'],
['模型名', 'model name'],
['变体', 'variants'],
['亲和性路由', 'affinity routing'],
['跨平台', 'cross-platform'],
['兼容格式', 'compatible format'],
['原生透传', 'native passthrough'],
['生态', 'ecosystem'],
['入口', 'entry'],
['响应', 'response'],
['一般', 'usually'],
['自定义', 'custom'],
['章节', 'section'],
['按周期', 'by cycle'],
['首次', 'first'],
['重试次数', 'retry count'],
['总超时时间', 'total timeout'],
['超时时间', 'timeout'],
['当前优先级', 'current priority'],
['规则', 'rules'],
['发送', 'send'],
['匹配', 'match'],
['一类', 'one class of'],
['标准模型列表', 'standard model list'],
['模型列表', 'model list'],
['低于', 'below'],
['各大', 'major'],
['金额下单', 'order amount'],
['实际收款金额', 'actual received amount'],
['通道手续费率', 'channel fee rate'],
['币种', 'currency'],
['最低充值金额', 'minimum top-up amount'],
['商户密钥', 'merchant key'],
['回调地址', 'callback URL'],
['接口地址', 'API URL'],
['后端', 'backend'],
['访问地址', 'access URL'],
['加密方式', 'encryption method'],
['常用端口', 'common ports'],
['无加密', 'no encryption'],
['应用专用密码', 'app-specific password'],
['发件人', 'sender'],
['邮箱地址', 'email address'],
['邮件服务', 'email service'],
['通知项', 'notification items'],
['模板', 'templates'],
['推送服务', 'push service'],
['黑白名单', 'blacklist/whitelist'],
['控制系统访问', 'control system access'],
['追踪安全事件', 'track security events'],
['变更记录', 'change records'],
['拖拽卡片调整顺序', 'drag cards to reorder'],
['细粒度', 'fine-grained'],
['用户认证', 'user authentication'],
['配置和参数', 'configuration and parameters'],
['清除系统数据', 'clear system data'],
['用户数据', 'user data'],
['一体化备份', 'integrated backup'],
['钱包快照', 'wallet snapshots'],
['品牌名称', 'brand name'],
['品牌名', 'brand name'],
['邮件中', 'emails'],
['卡密', 'redeem code'],
['兑换', 'redeem'],
['赠款余额', 'gift balance'],
['充值余额', 'top-up balance'],
['可退款余额', 'refundable balance'],
['每日额度套餐', 'daily quota plan'],
['未配置', 'not configured'],
['暂不可修改', 'temporarily cannot be changed'],
['退出其他设备', 'sign out other devices'],
['再次输入密码', 'enter password again'],
['设备', 'devices'],
['状态', 'status'],
['筛选用户', 'filter user'],
['筛选提供商', 'filter provider'],
['筛选类型', 'filter type'],
['总耗时', 'total duration'],
['输出速度', 'output speed'],
['最近24小时', 'last 24 hours'],
['自动刷新', 'auto refresh'],
['隐藏未知模型或提供商的请求', 'hide requests with unknown model or provider'],
['开启自动刷新', 'turn on auto refresh'],
['映射缓存', 'mapping cache'],
['解析缓存', 'resolution cache'],
['个键', 'keys'],
['因', 'due to'],
['次数', 'count'],
['读', 'read'],
['写', 'write'],
['慢请求', 'slow requests'],
['错误样本', 'error samples'],
['资源', 'resources'],
['数据流', 'data flow'],
['百分位', 'percentiles'],
['无上游样本', 'no upstream samples'],
['和', 'and'],
['与', 'and'],
['或', 'or'],
['的', ''],
['为', 'as'],
['在', 'in'],
['到', 'to'],
['从', 'from'],
['若', 'if'],
['请', 'please'],
['先', 'first'],
['再', 'then'],
['可', 'can'],
['会', 'will'],
['并', 'and'],
['且', 'and'],
['以', 'to'],
['于', 'at'],
['将', 'will'],
['天', 'days'],
['小时', 'hours'],
['分钟', 'minutes'],
['秒', 'seconds'],
]
const legacySortedPhraseMessages = [...legacyPhraseEnglishMessages]
.filter(([source]) => source.length > 1)
.sort((a, b) => b[0].length - a[0].length)
const legacySortedReplacementTokens = [
...Object.entries(legacyExactEnglishMessages).filter(([source]) => source.length > 1),
...legacyFallbackTokens.filter(([source]) => source.length > 1),
].sort((a, b) => b[0].length - a[0].length)
const legacyParticleTokens: Array<[string, string]> = [
['以及', ' and '],
['或者', ' or '],
['并且', ' and '],
['不含', ' excluding '],
['暂无', ' no '],
['是否', ' whether '],
['这里', ' here '],
['你的', ' your '],
['您的', ' your '],
['这个', ' this '],
['这种', ' this '],
['那个', ' that '],
['全部', ' all '],
['所有', ' all '],
['不同', ' different '],
['多种', ' multiple '],
['各种', ' various '],
['每个', ' each '],
['一个', ' one '],
['一些', ' some '],
['因', ' due to '],
['先', ' first '],
['再', ' then '],
['非', ' non-'],
['没有', ' no '],
['无法', ' unable to '],
['不能', ' cannot '],
['不会', ' will not '],
['不用', ' do not need to '],
['需要', ' need to '],
['可以', ' can '],
['支持', ' supports '],
['使用', ' use '],
['通过', ' through '],
['根据', ' according to '],
['进入', ' enter '],
['显示', ' display '],
['影响', ' affects '],
['包含', ' includes '],
['排查', ' troubleshoot '],
['关联', ' linked '],
['进行', ' perform '],
['检查', ' check '],
['收到', ' receives '],
['开通', ' activate '],
['消耗', ' consume '],
['累计', ' cumulative '],
['待处理', ' pending '],
['立即', ' now '],
['的', ' '],
['与', ' and '],
['和', ' and '],
['或', ' or '],
['及', ' and '],
['并', ' and '],
['而', ' '],
['从', ' from '],
['到', ' to '],
['在', ' in '],
['为', ' as '],
['将', ' will '],
['可', ' can '],
['会', ' will '],
['时', ' when '],
['后', ' after '],
['前', ' before '],
['内', ' within '],
['上', ' on '],
['下', ' next '],
['个', ' '],
['条', ' items '],
['行', ' rows '],
['项', ' items '],
['方', ' side '],
['性', ''],
['取', ''],
['锁', ' lock'],
['次', ' times '],
]
const cjkPattern = /[\u4e00-\u9fff]/
function preserveOuterWhitespace(source: string, value: string): string {
@@ -3687,50 +3021,14 @@ function preserveOuterWhitespace(source: string, value: string): string {
return `${leading}${value}${trailing}`
}
function normalizeLegacyEnglish(value: string): string {
return value
.replace(//g, ' (')
.replace(//g, ')')
.replace(//g, ': ')
.replace(//g, ', ')
.replace(/。/g, '.')
.replace(//g, '; ')
.replace(/、/g, ' / ')
.replace(/“|”/g, '"')
.replace(/…/g, '...')
.replace(/:([A-Za-z])/g, ': $1')
.replace(/Priority:key/g, 'Priority: key')
.replace(/\s+/g, ' ')
.replace(/\s+([,.:;!?)])/g, '$1')
.replace(/([(])\s+/g, '$1')
.trim()
}
function translateLegacyByTokens(source: string): string {
let value = source
for (const [from, to] of legacySortedPhraseMessages) {
value = value.split(from).join(to)
}
for (const [from, to] of legacySortedReplacementTokens) {
value = value.split(from).join(to)
}
for (const [from, to] of legacyParticleTokens) {
value = value.split(from).join(to)
}
return normalizeLegacyEnglish(value)
}
export function translateLegacyText(source: string, locale: keyof typeof messages): string {
if (locale !== 'en-US' || !cjkPattern.test(source)) {
return source
}
const trimmed = source.trim()
const exact = legacyExactEnglishMessages[trimmed]
const normalized = trimmed.replace(/\s+/gu, ' ')
const exact = legacyStaticEnglishMessages[trimmed] ?? legacyStaticEnglishMessages[normalized]
if (exact) {
return preserveOuterWhitespace(source, exact)
}
@@ -3742,8 +3040,8 @@ export function translateLegacyText(source: string, locale: keyof typeof message
}
}
const fallback = translateLegacyByTokens(trimmed)
return preserveOuterWhitespace(source, fallback)
// Unknown text may contain user-provided names or content, so never translate fragments.
return source
}
export type Locale = keyof typeof messages
+34 -26
View File
@@ -6,10 +6,10 @@
:content-class="contentClasses"
>
<template #notice>
<div class="flex w-full max-w-3xl items-center justify-between rounded-3xl bg-orange-500 px-6 py-3 text-white shadow-2xl ring-1 ring-white/30">
<div class="flex items-center gap-3">
<AlertTriangle class="h-5 w-5" />
<span>{{ t('auth.expired') }}</span>
<div class="flex w-full max-w-3xl flex-wrap items-center justify-between gap-3 rounded-3xl bg-orange-500 px-6 py-3 text-white shadow-2xl ring-1 ring-white/30">
<div class="flex min-w-0 items-center gap-3">
<AlertTriangle class="h-5 w-5 shrink-0" />
<span class="break-words">{{ t('auth.expired') }}</span>
</div>
<Button
variant="outline"
@@ -149,20 +149,20 @@
</div>
<div
class="flex items-center gap-1"
class="flex shrink-0 items-center gap-1"
:class="sidebarCollapsed ? 'flex-col' : ''"
>
<RouterLink
to="/dashboard/settings"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
:aria-label="sidebarCollapsed ? t('common.settings') : undefined"
:aria-label="t('common.settings')"
:title="t('common.settings')"
>
<Settings class="h-4 w-4" />
</RouterLink>
<button
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:text-red-500"
:aria-label="sidebarCollapsed ? t('common.logout') : undefined"
:aria-label="t('common.logout')"
:title="t('common.logout')"
@click="handleLogout"
>
@@ -178,27 +178,27 @@
<template #header>
<!-- Mobile Header (matches Home page style) -->
<header class="lg:hidden fixed top-0 left-0 right-0 z-50 border-b border-[var(--shell-border)] bg-[var(--shell-glass)] backdrop-blur-xl transition-all">
<div class="mx-auto max-w-7xl px-6 py-4">
<div class="flex items-center justify-between">
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6">
<div class="flex min-w-0 items-center justify-between gap-2">
<!-- Logo & Brand -->
<RouterLink
to="/"
class="flex items-center gap-3 group"
class="group flex min-w-0 items-center gap-2 sm:gap-3"
>
<HeaderLogo
size="h-9 w-9"
class-name="text-[#191919] dark:text-white"
class-name="shrink-0 text-[#191919] dark:text-white"
/>
<div class="flex flex-col justify-center">
<h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none">
<div class="flex min-w-0 flex-col justify-center">
<h1 class="truncate text-lg font-bold text-[#191919] dark:text-white leading-none">
{{ siteName }}
</h1>
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">{{ siteSubtitle }}</span>
<span class="mt-1.5 truncate text-[10px] font-medium leading-none tracking-normal text-[#91918d] dark:text-muted-foreground">{{ siteSubtitle }}</span>
</div>
</RouterLink>
<!-- Right Actions -->
<div class="flex items-center gap-3">
<div class="flex shrink-0 items-center gap-0.5 sm:gap-3">
<VersionButton
v-if="isAdmin"
:status="versionStatus"
@@ -219,7 +219,10 @@
<LanguageSwitcher />
<ThemeModeButton />
<button
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:aria-label="mobileMenuOpen ? t('common.closeMenu') : t('common.openMenu')"
:aria-expanded="mobileMenuOpen"
aria-controls="mobile-navigation"
@click="mobileMenuOpen = !mobileMenuOpen"
>
<div class="relative w-5 h-5">
@@ -258,6 +261,7 @@
>
<div
v-if="mobileMenuOpen"
id="mobile-navigation"
class="absolute inset-x-0 top-full max-h-[calc(100dvh-73px)] overflow-y-auto overscroll-contain border-t border-[var(--shell-border)] bg-background shadow-xl [-webkit-overflow-scrolling:touch] touch-pan-y"
>
<div class="mx-auto max-w-7xl px-6 py-4 pb-28">
@@ -278,7 +282,7 @@
v-for="item in group.items"
:key="item.href"
:to="item.href"
class="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all"
class="flex min-w-0 items-center gap-2.5 px-3 py-2.5 rounded-xl text-sm font-medium transition-all"
:class="isNavActive(item.href)
? 'bg-[#cc785c]/10 dark:bg-[#cc785c]/20 text-[#cc785c] dark:text-[#d4a27f]'
: 'text-[#666663] dark:text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5 hover:text-[#191919] dark:hover:text-white'"
@@ -291,7 +295,7 @@
:is="item.icon"
class="h-4 w-4 shrink-0"
/>
<span class="truncate">{{ item.name }}</span>
<span class="min-w-0 break-words leading-5">{{ item.name }}</span>
</RouterLink>
</div>
</div>
@@ -309,11 +313,12 @@
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1">{{ currentRoleLabel }}</span>
</div>
</div>
<div class="flex items-center gap-1">
<div class="flex shrink-0 items-center gap-1">
<RouterLink
to="/dashboard/settings"
class="p-2 hover:bg-muted/50 rounded-lg text-muted-foreground hover:text-foreground transition-colors"
:title="t('common.settings')"
:aria-label="t('common.settings')"
@click="mobileMenuOpen = false"
>
<Settings class="w-4 h-4" />
@@ -321,6 +326,7 @@
<button
class="p-2 rounded-lg text-muted-foreground hover:text-red-500 transition-colors"
:title="t('common.logout')"
:aria-label="t('common.logout')"
@click="handleLogout"
>
<LogOut class="w-4 h-4" />
@@ -334,25 +340,26 @@
</header>
<!-- Desktop Page Header -->
<header class="hidden lg:flex h-16 px-8 items-center justify-between shrink-0 border-b border-[#3d3929]/5 dark:border-white/5 sticky top-0 z-40 backdrop-blur-md bg-[#faf9f5]/90 dark:bg-[#191714]/90">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<header class="hidden lg:flex min-h-16 gap-4 px-8 py-3 items-center justify-between shrink-0 border-b border-[#3d3929]/5 dark:border-white/5 sticky top-0 z-40 backdrop-blur-md bg-[#faf9f5]/90 dark:bg-[#191714]/90">
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 flex-wrap items-center gap-2 text-sm text-muted-foreground">
<template
v-for="(crumb, index) in breadcrumbs"
:key="index"
>
<template v-if="index > 0">
<ChevronRight class="w-3 h-3 opacity-50" />
<ChevronRight class="w-3 h-3 shrink-0 opacity-50" />
</template>
<RouterLink
v-if="crumb.href && index < breadcrumbs.length - 1"
:to="crumb.href"
class="hover:text-foreground transition-colors"
class="min-w-0 break-words hover:text-foreground transition-colors"
>
{{ crumb.label }}
</RouterLink>
<span
v-else
class="min-w-0 break-words"
:class="index === breadcrumbs.length - 1 ? 'text-foreground font-medium' : ''"
>
{{ crumb.label }}
@@ -366,13 +373,13 @@
<!-- Demo Mode Badge (center) -->
<div
v-if="isDemo"
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs font-medium"
class="flex shrink-0 items-center gap-2 px-3 py-1.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-xs font-medium"
>
<AlertTriangle class="w-3.5 h-3.5" />
<span>{{ t('demo.mode') }}</span>
</div>
<div class="flex items-center gap-2">
<div class="flex shrink-0 items-center gap-2">
<!-- Page-level header actions (right side) -->
<div
id="header-actions-right"
@@ -405,6 +412,7 @@
rel="noopener noreferrer"
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
:title="t('common.githubRepository')"
:aria-label="t('common.githubRepository')"
>
<GithubIcon class="h-4 w-4" />
</a>
+1 -1
View File
@@ -983,7 +983,7 @@ body[theme-mode='dark'] .literary-annotation {
.app-shell__main {
/* Main content area with padding */
@apply flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8;
@apply min-w-0 flex-1 px-4 py-6 sm:px-6 lg:px-8 lg:py-8;
}
.surface-glass {
+4 -2
View File
@@ -1,3 +1,5 @@
import { getI18nLocale } from '@/i18n'
import type {
BillingEntitlement,
UsagePolicyEntitlement,
@@ -100,7 +102,7 @@ function formatCalendarWeek(timezone?: string, weekStart?: number): string {
}
function formatUsd(limit: number): string {
return `$${Number(limit || 0).toLocaleString('zh-CN', { maximumFractionDigits: 8 })}`
return `$${Number(limit || 0).toLocaleString(getI18nLocale(), { maximumFractionDigits: 8 })}`
}
function formatWindowDuration(seconds: number): string {
@@ -111,5 +113,5 @@ function formatWindowDuration(seconds: number): string {
}
function formatLimit(limit: number): string {
return Number(limit || 0).toLocaleString('zh-CN')
return Number(limit || 0).toLocaleString(getI18nLocale())
}
+4
View File
@@ -163,6 +163,10 @@ export function formatDate(dateString: string | undefined | null): string {
})
}
export function formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit): string {
return new Intl.RelativeTimeFormat(getI18nLocale(), { numeric: 'auto' }).format(value, unit)
}
// Model price formatting (already in per 1M tokens)
export function formatModelPrice(price: number | undefined | null): string {
if (price === undefined || price === null) {
@@ -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()
})
})