mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-07 08:50:22 +08:00
* feat(frontend): 前端界面优化与使用统计功能增强
主要改动:
1. 表格布局统一优化
- 新增 TablePageLayout 通用布局组件
- 统一所有管理页面的表格样式和交互
- 优化 DataTable、Pagination、Select 等通用组件
2. 使用统计功能增强
- 管理端: 添加完整的筛选和显示功能
- 用户端: 完善 API Key 列显示
- 后端: 优化使用统计数据结构和查询
3. 账户组件优化
- 优化 AccountStatsModal、AccountUsageCell 等组件
- 统一进度条和统计显示样式
4. 其他改进
- 完善中英文国际化
- 统一页面样式和交互体验
- 优化各视图页面的响应式布局
* fix(test): 修复 stubUsageLogRepo.ListWithFilters 测试 stub
测试用例 GET /api/v1/usage 返回 500 是因为 stub 方法未实现,
现在正确返回基于 UserID 过滤的日志数据。
* feat(frontend): 统一日期时间显示格式
**主要改动**:
1. 增强 utils/format.ts:
- 新增 formatDateOnly() - 格式: YYYY-MM-DD
- 新增 formatDateTime() - 格式: YYYY-MM-DD HH:mm:ss
2. 全局替换视图中的格式化函数:
- 移除各视图中的自定义 formatDate 函数
- 统一导入使用 @/utils/format 中的函数
- created_at/updated_at 使用 formatDateTime
- expires_at 使用 formatDateOnly
3. 受影响的视图 (8个):
- frontend/src/views/user/KeysView.vue
- frontend/src/views/user/DashboardView.vue
- frontend/src/views/user/UsageView.vue
- frontend/src/views/user/RedeemView.vue
- frontend/src/views/admin/UsersView.vue
- frontend/src/views/admin/UsageView.vue
- frontend/src/views/admin/RedeemView.vue
- frontend/src/views/admin/SubscriptionsView.vue
**效果**:
- 日期统一显示为 YYYY-MM-DD
- 时间统一显示为 YYYY-MM-DD HH:mm:ss
- 提升可维护性,避免格式不一致
* fix(frontend): 补充遗漏的时间格式化统一
**补充修复**(基于 code review 发现的遗漏):
1. 增强 utils/format.ts:
- 新增 formatTime() - 格式: HH:mm
2. 修复 4 个遗漏的文件:
- src/views/admin/UsersView.vue
* 删除 formatExpiresAt(),改用 formatDateTime()
* 修复订阅过期时间 tooltip 显示格式不一致问题
- src/views/user/ProfileView.vue
* 删除 formatMemberSince(),改用 formatDate(date, 'YYYY-MM')
* 统一会员起始时间显示格式
- src/views/user/SubscriptionsView.vue
* 修改 formatExpirationDate() 使用 formatDateOnly()
* 保留天数计算逻辑
- src/components/account/AccountStatusIndicator.vue
* 删除本地 formatTime(),改用 utils/format 中的统一函数
* 修复 rate limit 和 overload 重置时间显示
**验证**:
- TypeScript 类型检查通过 ✓
- 前端构建成功 ✓
- 所有剩余的 toLocaleString() 都是数字格式化,属于正确用法 ✓
**效果**:
- 订阅过期时间统一为 YYYY-MM-DD HH:mm:ss
- 会员起始时间统一为 YYYY-MM
- 重置时间统一为 HH:mm
- 消除所有不规范的原生 locale 方法调用
328 lines
8.9 KiB
Vue
328 lines
8.9 KiB
Vue
<template>
|
|
<div class="relative" ref="containerRef">
|
|
<button
|
|
type="button"
|
|
@click="toggle"
|
|
:disabled="disabled"
|
|
:class="[
|
|
'select-trigger',
|
|
isOpen && 'select-trigger-open',
|
|
error && 'select-trigger-error',
|
|
disabled && 'select-trigger-disabled'
|
|
]"
|
|
>
|
|
<span class="select-value">
|
|
<slot name="selected" :option="selectedOption">
|
|
{{ selectedLabel }}
|
|
</slot>
|
|
</span>
|
|
<span class="select-icon">
|
|
<svg
|
|
:class="['h-5 w-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
|
|
<Transition name="select-dropdown">
|
|
<div v-if="isOpen" class="select-dropdown">
|
|
<!-- Search input -->
|
|
<div v-if="searchable" class="select-search">
|
|
<svg
|
|
class="h-4 w-4 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
/>
|
|
</svg>
|
|
<input
|
|
ref="searchInputRef"
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="searchPlaceholderText"
|
|
class="select-search-input"
|
|
@click.stop
|
|
/>
|
|
</div>
|
|
|
|
<!-- Options list -->
|
|
<div class="select-options">
|
|
<div
|
|
v-for="option in filteredOptions"
|
|
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
|
|
@click="selectOption(option)"
|
|
:class="['select-option', isSelected(option) && 'select-option-selected']"
|
|
>
|
|
<slot name="option" :option="option" :selected="isSelected(option)">
|
|
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
|
|
<svg
|
|
v-if="isSelected(option)"
|
|
class="h-4 w-4 text-primary-500"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
</slot>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-if="filteredOptions.length === 0" class="select-empty">
|
|
{{ emptyTextDisplay }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
const { t } = useI18n()
|
|
|
|
export interface SelectOption {
|
|
value: string | number | boolean | null
|
|
label: string
|
|
disabled?: boolean
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface Props {
|
|
modelValue: string | number | boolean | null | undefined
|
|
options: SelectOption[] | Array<Record<string, unknown>>
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
error?: boolean
|
|
searchable?: boolean
|
|
searchPlaceholder?: string
|
|
emptyText?: string
|
|
valueKey?: string
|
|
labelKey?: string
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'update:modelValue', value: string | number | boolean | null): void
|
|
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
disabled: false,
|
|
error: false,
|
|
searchable: false,
|
|
valueKey: 'value',
|
|
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 containerRef = ref<HTMLElement | null>(null)
|
|
const searchInputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
const getOptionValue = (
|
|
option: SelectOption | Record<string, unknown>
|
|
): string | number | boolean | null | undefined => {
|
|
if (typeof option === 'object' && option !== null) {
|
|
return option[props.valueKey] as string | number | boolean | null | undefined
|
|
}
|
|
return option as string | number | boolean | null
|
|
}
|
|
|
|
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
|
|
if (typeof option === 'object' && option !== null) {
|
|
return String(option[props.labelKey] ?? '')
|
|
}
|
|
return String(option ?? '')
|
|
}
|
|
|
|
const selectedOption = computed(() => {
|
|
return props.options.find((opt) => getOptionValue(opt) === props.modelValue) || null
|
|
})
|
|
|
|
const selectedLabel = computed(() => {
|
|
if (selectedOption.value) {
|
|
return getOptionLabel(selectedOption.value)
|
|
}
|
|
return placeholderText.value
|
|
})
|
|
|
|
const filteredOptions = computed(() => {
|
|
if (!props.searchable || !searchQuery.value) {
|
|
return props.options
|
|
}
|
|
const query = searchQuery.value.toLowerCase()
|
|
return props.options.filter((opt) => {
|
|
const label = getOptionLabel(opt).toLowerCase()
|
|
return label.includes(query)
|
|
})
|
|
})
|
|
|
|
const isSelected = (option: SelectOption | Record<string, unknown>): boolean => {
|
|
return getOptionValue(option) === props.modelValue
|
|
}
|
|
|
|
const toggle = () => {
|
|
if (props.disabled) return
|
|
isOpen.value = !isOpen.value
|
|
if (isOpen.value && props.searchable) {
|
|
nextTick(() => {
|
|
searchInputRef.value?.focus()
|
|
})
|
|
}
|
|
}
|
|
|
|
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 handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
|
isOpen.value = false
|
|
searchQuery.value = ''
|
|
}
|
|
}
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape' && 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)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.select-trigger {
|
|
@apply flex w-full items-center justify-between gap-2;
|
|
@apply rounded-xl px-4 py-2.5 text-sm;
|
|
@apply bg-white dark:bg-dark-800;
|
|
@apply border border-gray-200 dark:border-dark-600;
|
|
@apply text-gray-900 dark:text-gray-100;
|
|
@apply transition-all duration-200;
|
|
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
|
|
@apply hover:border-gray-300 dark:hover:border-dark-500;
|
|
@apply cursor-pointer;
|
|
}
|
|
|
|
.select-trigger-open {
|
|
@apply border-primary-500 ring-2 ring-primary-500/30;
|
|
}
|
|
|
|
.select-trigger-error {
|
|
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
|
|
}
|
|
|
|
.select-trigger-disabled {
|
|
@apply cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900;
|
|
}
|
|
|
|
.select-value {
|
|
@apply flex-1 truncate text-left;
|
|
}
|
|
|
|
.select-icon {
|
|
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
|
|
}
|
|
|
|
.select-dropdown {
|
|
@apply absolute z-[100] mt-2 w-full;
|
|
@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;
|
|
}
|
|
|
|
.select-search {
|
|
@apply flex items-center gap-2 px-3 py-2;
|
|
@apply border-b border-gray-100 dark:border-dark-700;
|
|
}
|
|
|
|
.select-search-input {
|
|
@apply flex-1 bg-transparent text-sm;
|
|
@apply text-gray-900 dark:text-gray-100;
|
|
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
|
|
@apply focus:outline-none;
|
|
}
|
|
|
|
.select-options {
|
|
@apply max-h-60 overflow-y-auto py-1;
|
|
}
|
|
|
|
.select-option {
|
|
@apply flex items-center justify-between gap-2;
|
|
@apply px-4 py-2.5 text-sm;
|
|
@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;
|
|
}
|
|
|
|
.select-option-selected {
|
|
@apply bg-primary-50 dark:bg-primary-900/20;
|
|
@apply text-primary-700 dark:text-primary-300;
|
|
}
|
|
|
|
.select-option-label {
|
|
@apply truncate;
|
|
}
|
|
|
|
.select-empty {
|
|
@apply px-4 py-8 text-center text-sm;
|
|
@apply text-gray-500 dark:text-dark-400;
|
|
}
|
|
|
|
/* Dropdown animation */
|
|
.select-dropdown-enter-active,
|
|
.select-dropdown-leave-active {
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.select-dropdown-enter-from,
|
|
.select-dropdown-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-8px);
|
|
}
|
|
</style>
|