fix(frontend): comprehensive i18n cleanup and Select component hardening

This commit is contained in:
IanShaw027
2026-01-04 21:09:14 +08:00
parent c86d445cb7
commit d36392b74f
12 changed files with 374 additions and 188 deletions

View File

@@ -1,8 +1,8 @@
<template>
<div>
<label class="input-label">
Groups
<span class="font-normal text-gray-400">({{ modelValue.length }} selected)</span>
{{ t('admin.users.groups') }}
<span class="font-normal text-gray-400">{{ t('common.selectedCount', { count: modelValue.length }) }}</span>
</label>
<div
class="grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800"
@@ -32,7 +32,7 @@
v-if="filteredGroups.length === 0"
class="col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400"
>
No groups available
{{ t('common.noGroupsAvailable') }}
</div>
</div>
</div>

View File

@@ -1,15 +1,21 @@
<template>
<div class="relative" ref="containerRef">
<button
ref="triggerRef"
type="button"
@click="toggle"
:disabled="disabled"
:aria-expanded="isOpen"
:aria-haspopup="true"
aria-label="Select option"
:class="[
'select-trigger',
isOpen && 'select-trigger-open',
error && 'select-trigger-error',
disabled && 'select-trigger-disabled'
]"
@keydown.down.prevent="onTriggerKeyDown"
@keydown.up.prevent="onTriggerKeyDown"
>
<span class="select-value">
<slot name="selected" :option="selectedOption">
@@ -29,16 +35,19 @@
</span>
</button>
<!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
<!-- Teleport dropdown to body to escape stacking context -->
<Teleport to="body">
<Transition name="select-dropdown">
<div
v-if="isOpen"
ref="dropdownRef"
class="select-dropdown-portal"
:class="[instanceId]"
:style="dropdownStyle"
role="listbox"
@click.stop
@mousedown.stop
@keydown="onDropdownKeyDown"
>
<!-- Search input -->
<div v-if="searchable" class="select-search">
@@ -66,12 +75,21 @@
</div>
<!-- Options list -->
<div class="select-options">
<div class="select-options" ref="optionsListRef">
<div
v-for="option in filteredOptions"
v-for="(option, index) in filteredOptions"
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
@click.stop="selectOption(option)"
:class="['select-option', isSelected(option) && 'select-option-selected']"
role="option"
:aria-selected="isSelected(option)"
:aria-disabled="isOptionDisabled(option)"
@click.stop="!isOptionDisabled(option) && selectOption(option)"
@mouseenter="focusedIndex = index"
:class="[
'select-option',
isSelected(option) && 'select-option-selected',
isOptionDisabled(option) && 'select-option-disabled',
focusedIndex === index && 'select-option-focused'
]"
>
<slot name="option" :option="option" :selected="isSelected(option)">
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
@@ -105,6 +123,9 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// Instance ID for unique click-outside detection
const instanceId = `select-${Math.random().toString(36).substring(2, 9)}`
export interface SelectOption {
value: string | number | boolean | null
label: string
@@ -138,23 +159,24 @@ const props = withDefaults(defineProps<Props>(), {
labelKey: 'label'
})
// Use computed for i18n default values
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
const searchPlaceholderText = computed(
() => props.searchPlaceholder ?? t('common.searchPlaceholder')
)
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
const emit = defineEmits<Emits>()
const isOpen = ref(false)
const searchQuery = ref('')
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLButtonElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const optionsListRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const triggerRect = ref<DOMRect | null>(null)
// i18n placeholders
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
// Computed style for teleported dropdown
const dropdownStyle = computed(() => {
if (!triggerRect.value) return {}
@@ -164,34 +186,39 @@ const dropdownStyle = computed(() => {
position: 'fixed',
left: `${rect.left}px`,
minWidth: `${rect.width}px`,
zIndex: '100000020' // Higher than driver.js overlay (99999998)
zIndex: '100000020'
}
if (dropdownPosition.value === 'top') {
style.bottom = `${window.innerHeight - rect.top + 8}px`
style.bottom = `${window.innerHeight - rect.top + 4}px`
} else {
style.top = `${rect.bottom + 8}px`
style.top = `${rect.bottom + 4}px`
}
return style
})
const getOptionValue = (
option: SelectOption | Record<string, unknown>
): string | number | boolean | null | undefined => {
const getOptionValue = (option: any): any => {
if (typeof option === 'object' && option !== null) {
return option[props.valueKey] as string | number | boolean | null | undefined
return option[props.valueKey]
}
return option as string | number | boolean | null
return option
}
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
const getOptionLabel = (option: any): string => {
if (typeof option === 'object' && option !== null) {
return String(option[props.labelKey] ?? '')
}
return String(option ?? '')
}
const isOptionDisabled = (option: any): boolean => {
if (typeof option === 'object' && option !== null) {
return !!option.disabled
}
return false
}
const selectedOption = computed(() => {
return props.options.find((opt) => getOptionValue(opt) === props.modelValue) || null
})
@@ -204,36 +231,35 @@ const selectedLabel = computed(() => {
})
const filteredOptions = computed(() => {
if (!props.searchable || !searchQuery.value) {
return props.options
let opts = props.options as any[]
if (props.searchable && searchQuery.value) {
const query = searchQuery.value.toLowerCase()
opts = opts.filter((opt) => getOptionLabel(opt).toLowerCase().includes(query))
}
const query = searchQuery.value.toLowerCase()
return props.options.filter((opt) => {
const label = getOptionLabel(opt).toLowerCase()
return label.includes(query)
})
return opts
})
const isSelected = (option: SelectOption | Record<string, unknown>): boolean => {
const isSelected = (option: any): boolean => {
return getOptionValue(option) === props.modelValue
}
// Update trigger rect periodically while open to follow scroll/resize
const updateTriggerRect = () => {
if (containerRef.value) {
triggerRect.value = containerRef.value.getBoundingClientRect()
}
}
const calculateDropdownPosition = () => {
if (!containerRef.value) return
// Update trigger rect for positioning
triggerRect.value = containerRef.value.getBoundingClientRect()
updateTriggerRect()
nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return
if (!dropdownRef.value || !triggerRect.value) return
const dropdownHeight = dropdownRef.value.offsetHeight || 240
const spaceBelow = window.innerHeight - triggerRect.value.bottom
const spaceAbove = triggerRect.value.top
const rect = triggerRect.value!
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - rect.bottom
const spaceAbove = rect.top
// If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
dropdownPosition.value = 'top'
} else {
@@ -245,63 +271,108 @@ const calculateDropdownPosition = () => {
const toggle = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
if (isOpen.value) {
}
watch(isOpen, (open) => {
if (open) {
calculateDropdownPosition()
// Reset focused index to current selection or first item
const selectedIdx = filteredOptions.value.findIndex(isSelected)
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
if (props.searchable) {
nextTick(() => {
searchInputRef.value?.focus()
})
nextTick(() => searchInputRef.value?.focus())
}
// Add scroll listener to update position
window.addEventListener('scroll', updateTriggerRect, { capture: true, passive: true })
window.addEventListener('resize', calculateDropdownPosition)
} else {
searchQuery.value = ''
focusedIndex.value = -1
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
}
})
const selectOption = (option: any) => {
const value = getOptionValue(option) ?? null
emit('update:modelValue', value)
emit('change', value, option)
isOpen.value = false
triggerRef.value?.focus()
}
// Keyboards
const onTriggerKeyDown = () => {
if (!isOpen.value) {
isOpen.value = true
}
}
const selectOption = (option: SelectOption | Record<string, unknown>) => {
const value = getOptionValue(option) ?? null
emit('update:modelValue', value)
emit('change', value, option as SelectOption)
isOpen.value = false
searchQuery.value = ''
const onDropdownKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
focusedIndex.value = (focusedIndex.value + 1) % filteredOptions.value.length
scrollToFocused()
break
case 'ArrowUp':
e.preventDefault()
focusedIndex.value = (focusedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
scrollToFocused()
break
case 'Enter':
e.preventDefault()
if (focusedIndex.value >= 0 && focusedIndex.value < filteredOptions.value.length) {
const opt = filteredOptions.value[focusedIndex.value]
if (!isOptionDisabled(opt)) selectOption(opt)
}
break
case 'Escape':
e.preventDefault()
isOpen.value = false
triggerRef.value?.focus()
break
case 'Tab':
isOpen.value = false
break
}
}
const scrollToFocused = () => {
nextTick(() => {
const list = optionsListRef.value
if (!list) return
const focusedEl = list.children[focusedIndex.value] as HTMLElement
if (!focusedEl) return
if (focusedEl.offsetTop < list.scrollTop) {
list.scrollTop = focusedEl.offsetTop
} else if (focusedEl.offsetTop + focusedEl.offsetHeight > list.scrollTop + list.offsetHeight) {
list.scrollTop = focusedEl.offsetTop + focusedEl.offsetHeight - list.offsetHeight
}
})
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
// Check if click is inside THIS specific instance's dropdown or trigger
const isInDropdown = !!target.closest(`.${instanceId}`)
const isInTrigger = containerRef.value?.contains(target)
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref
if (target.closest('.select-dropdown-portal')) {
return // 点击在下拉菜单内,不关闭
}
// 检查是否点击在触发器内
if (containerRef.value && containerRef.value.contains(target)) {
return // 点击在触发器内,让 toggle 处理
}
// 点击在外部,关闭下拉菜单
isOpen.value = false
searchQuery.value = ''
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen.value) {
if (!isInDropdown && !isInTrigger && isOpen.value) {
isOpen.value = false
searchQuery.value = ''
}
}
watch(isOpen, (open) => {
if (!open) {
searchQuery.value = ''
}
})
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
})
</script>
@@ -339,16 +410,14 @@ onUnmounted(() => {
}
</style>
<!-- Global styles for teleported dropdown -->
<style>
.select-dropdown-portal {
@apply w-max max-w-[300px];
@apply w-max min-w-[160px] max-w-[320px];
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden;
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
pointer-events: auto !important;
}
@@ -365,7 +434,7 @@ onUnmounted(() => {
}
.select-dropdown-portal .select-options {
@apply max-h-60 overflow-y-auto py-1;
@apply max-h-60 overflow-y-auto py-1 outline-none;
}
.select-dropdown-portal .select-option {
@@ -374,7 +443,6 @@ onUnmounted(() => {
@apply text-gray-700 dark:text-gray-300;
@apply cursor-pointer transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
/* 确保选项在引导期间可点击 */
pointer-events: auto !important;
}
@@ -383,6 +451,14 @@ onUnmounted(() => {
@apply text-primary-700 dark:text-primary-300;
}
.select-dropdown-portal .select-option-focused {
@apply bg-gray-100 dark:bg-dark-700;
}
.select-dropdown-portal .select-option-disabled {
@apply cursor-not-allowed opacity-40;
}
.select-dropdown-portal .select-option-label {
@apply flex-1 min-w-0 truncate text-left;
}
@@ -392,7 +468,6 @@ onUnmounted(() => {
@apply text-gray-500 dark:text-dark-400;
}
/* Dropdown animation */
.select-dropdown-enter-active,
.select-dropdown-leave-active {
transition: all 0.2s ease;
@@ -403,4 +478,4 @@ onUnmounted(() => {
opacity: 0;
transform: translateY(-8px);
}
</style>
</style>

View File

@@ -1,5 +1,8 @@
import { ref } from 'vue'
import { useAppStore } from '@/stores/app'
import { i18n } from '@/i18n'
const { t } = i18n.global
/**
* 检测是否支持 Clipboard API需要安全上下文HTTPS/localhost
@@ -31,7 +34,7 @@ export function useClipboard() {
const copyToClipboard = async (
text: string,
successMessage = 'Copied to clipboard'
successMessage?: string
): Promise<boolean> => {
if (!text) return false
@@ -50,12 +53,12 @@ export function useClipboard() {
if (success) {
copied.value = true
appStore.showSuccess(successMessage)
appStore.showSuccess(successMessage || t('common.copiedToClipboard'))
setTimeout(() => {
copied.value = false
}, 2000)
} else {
appStore.showError('Copy failed')
appStore.showError(t('common.copyFailed'))
}
return success

View File

@@ -145,11 +145,13 @@ export default {
copiedToClipboard: 'Copied to clipboard',
copyFailed: 'Failed to copy',
contactSupport: 'Contact Support',
selectOption: 'Select an option',
searchPlaceholder: 'Search...',
noOptionsFound: 'No options found',
saving: 'Saving...',
refresh: 'Refresh',
selectOption: 'Select an option',
searchPlaceholder: 'Search...',
noOptionsFound: 'No options found',
noGroupsAvailable: 'No groups available',
unknownError: 'Unknown error occurred',
saving: 'Saving...',
selectedCount: '({count} selected)', refresh: 'Refresh',
notAvailable: 'N/A',
now: 'Now',
unknown: 'Unknown',
@@ -687,6 +689,10 @@ export default {
failedToWithdraw: 'Failed to withdraw',
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal',
roles: {
admin: 'Admin',
user: 'User'
},
// Settings Dropdowns
filterSettings: 'Filter Settings',
columnSettings: 'Column Settings',

View File

@@ -145,7 +145,10 @@ export default {
selectOption: '请选择',
searchPlaceholder: '搜索...',
noOptionsFound: '无匹配选项',
noGroupsAvailable: '无可用分组',
unknownError: '发生未知错误',
saving: '保存中...',
selectedCount: '(已选 {count} 个)',
refresh: '刷新',
notAvailable: '不可用',
now: '现在',
@@ -679,10 +682,6 @@ export default {
admin: '管理员',
user: '用户'
},
statuses: {
active: '正常',
banned: '禁用'
},
form: {
emailLabel: '邮箱',
emailPlaceholder: '请输入邮箱',

View File

@@ -3,7 +3,7 @@
* 参考 CRS 项目的 format.js 实现
*/
import { i18n } from '@/i18n'
import { i18n, getLocale } from '@/i18n'
/**
* 格式化相对时间
@@ -39,33 +39,39 @@ export function formatRelativeTime(date: string | Date | null | undefined): stri
export function formatNumber(num: number | null | undefined): string {
if (num === null || num === undefined) return '0'
const locale = getLocale()
const absNum = Math.abs(num)
if (absNum >= 1e9) {
return (num / 1e9).toFixed(2) + 'B'
} else if (absNum >= 1e6) {
return (num / 1e6).toFixed(2) + 'M'
} else if (absNum >= 1e3) {
return (num / 1e3).toFixed(1) + 'K'
}
// Use Intl.NumberFormat for compact notation if supported and needed
// Note: Compact notation in 'zh' uses '万/亿', which is appropriate for Chinese
const formatter = new Intl.NumberFormat(locale, {
notation: absNum >= 10000 ? 'compact' : 'standard',
maximumFractionDigits: 1
})
return num.toLocaleString()
return formatter.format(num)
}
/**
* 格式化货币金额
* @param amount 金额
* @returns 格式化后的字符串,如 "$1.25" 或 "$0.000123"
* @param currency 货币代码,默认 USD
* @returns 格式化后的字符串,如 "$1.25"
*/
export function formatCurrency(amount: number | null | undefined): string {
export function formatCurrency(amount: number | null | undefined, currency: string = 'USD'): string {
if (amount === null || amount === undefined) return '$0.00'
// 小于 0.01 时显示更多小数位
if (amount > 0 && amount < 0.01) {
return '$' + amount.toFixed(6)
}
const locale = getLocale()
return '$' + amount.toFixed(2)
// For very small amounts, show more decimals
const fractionDigits = amount > 0 && amount < 0.01 ? 6 : 2
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
}).format(amount)
}
/**
@@ -89,57 +95,61 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
/**
* 格式化日期
* @param date 日期字符串或 Date 对象
* @param format 格式字符串,支持 YYYY, MM, DD, HH, mm, ss
* @param options Intl.DateTimeFormatOptions
* @returns 格式化后的日期字符串
*/
export function formatDate(
date: string | Date | null | undefined,
format: string = 'YYYY-MM-DD HH:mm:ss'
options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}
): string {
if (!date) return ''
const d = new Date(date)
if (isNaN(d.getTime())) return ''
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
const locale = getLocale()
return new Intl.DateTimeFormat(locale, options).format(d)
}
/**
* 格式化日期(只显示日期部分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期字符串,格式为 YYYY-MM-DD
* @returns 格式化后的日期字符串
*/
export function formatDateOnly(date: string | Date | null | undefined): string {
return formatDate(date, 'YYYY-MM-DD')
return formatDate(date, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
/**
* 格式化日期时间(完整格式)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的日期时间字符串,格式为 YYYY-MM-DD HH:mm:ss
* @returns 格式化后的日期时间字符串
*/
export function formatDateTime(date: string | Date | null | undefined): string {
return formatDate(date, 'YYYY-MM-DD HH:mm:ss')
return formatDate(date)
}
/**
* 格式化时间(只显示时分)
* @param date 日期字符串或 Date 对象
* @returns 格式化后的时间字符串,格式为 HH:mm
* @returns 格式化后的时间字符串
*/
export function formatTime(date: string | Date | null | undefined): string {
return formatDate(date, 'HH:mm')
}
return formatDate(date, {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}

View File

@@ -652,16 +652,4 @@ onMounted(() => {
</script>
<style scoped>
/* Compact Select styling for dashboard */
:deep(.select-trigger) {
@apply rounded-lg px-3 py-1.5 text-sm;
}
:deep(.select-dropdown) {
@apply rounded-lg;
}
:deep(.select-option) {
@apply px-3 py-2 text-sm;
}
</style>

View File

@@ -381,7 +381,7 @@
]"
></span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ value === 'active' ? t('common.active') : t('admin.users.disabled') }}
{{ t('admin.accounts.status.' + (value === 'disabled' ? 'inactive' : value)) }}
</span>
</div>
</template>

View File

@@ -1052,16 +1052,4 @@ watch(isDarkMode, () => {
</script>
<style scoped>
/* Compact Select styling for dashboard */
:deep(.select-trigger) {
@apply rounded-lg px-3 py-1.5 text-sm;
}
:deep(.select-dropdown) {
@apply rounded-lg;
}
:deep(.select-option) {
@apply px-3 py-2 text-sm;
}
</style>

View File

@@ -141,7 +141,7 @@
<template #cell-status="{ value }">
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-gray']">
{{ value }}
{{ t('admin.accounts.status.' + value) }}
</span>
</template>
@@ -501,7 +501,8 @@
<div
v-if="groupSelectorKeyId !== null && dropdownPosition"
ref="dropdownRef"
class="animate-in fade-in slide-in-from-top-2 fixed z-[9999] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
style="pointer-events: auto !important;"
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
>
<div class="max-h-64 overflow-y-auto p-1.5">