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