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
@@ -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',