refactor(frontend): UI/UX改进和组件优化

- DataTable组件操作列自适应
- 优化各种Modal弹窗
- 统一API调用方式(AbortSignal)
- 添加全局订阅状态管理
- 优化各管理视图的交互和布局
- 修复国际化翻译问题
This commit is contained in:
IanShaw027
2025-12-28 01:00:06 +08:00
parent 9bbe468c91
commit 506cb21cb1
46 changed files with 1582 additions and 644 deletions

View File

@@ -1,5 +1,5 @@
<template>
<Modal :show="show" :title="title" size="sm" @close="handleCancel">
<BaseDialog :show="show" :title="title" width="narrow" @close="handleCancel">
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ message }}</p>
</div>
@@ -27,13 +27,13 @@
</button>
</div>
</template>
</Modal>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from './Modal.vue'
import BaseDialog from './BaseDialog.vue'
const { t } = useI18n()

View File

@@ -182,8 +182,8 @@ const checkActionsColumnWidth = () => {
// 等待DOM更新
nextTick(() => {
// 测量所有按钮的总宽度
const buttons = actionsContainer.querySelectorAll('button')
if (buttons.length <= 2) {
const actionItems = actionsContainer.querySelectorAll('button, a, [role="button"]')
if (actionItems.length <= 2) {
actionsColumnNeedsExpanding.value = false
actionsExpanded.value = wasExpanded
return
@@ -191,9 +191,9 @@ const checkActionsColumnWidth = () => {
// 计算所有按钮的总宽度包括gap
let totalWidth = 0
buttons.forEach((btn, index) => {
totalWidth += (btn as HTMLElement).offsetWidth
if (index < buttons.length - 1) {
actionItems.forEach((item, index) => {
totalWidth += (item as HTMLElement).offsetWidth
if (index < actionItems.length - 1) {
totalWidth += 4 // gap-1 = 4px
}
})

View File

@@ -206,10 +206,6 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value
emit('update:pageSize', newPageSize)
// Reset to first page when page size changes
if (props.page !== 1) {
emit('update:page', 1)
}
}
</script>

View File

@@ -30,7 +30,11 @@
</button>
<Transition name="select-dropdown">
<div v-if="isOpen" class="select-dropdown">
<div
v-if="isOpen"
ref="dropdownRef"
:class="['select-dropdown', dropdownPosition === 'top' && 'select-dropdown-top']"
>
<!-- Search input -->
<div v-if="searchable" class="select-search">
<svg
@@ -141,6 +145,8 @@ const isOpen = ref(false)
const searchQuery = ref('')
const containerRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const getOptionValue = (
option: SelectOption | Record<string, unknown>
@@ -184,13 +190,37 @@ const isSelected = (option: SelectOption | Record<string, unknown>): boolean =>
return getOptionValue(option) === props.modelValue
}
const calculateDropdownPosition = () => {
if (!containerRef.value) return
nextTick(() => {
if (!containerRef.value || !dropdownRef.value) return
const triggerRect = containerRef.value.getBoundingClientRect()
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
const viewportHeight = window.innerHeight
const spaceBelow = viewportHeight - triggerRect.bottom
const spaceAbove = triggerRect.top
// If not enough space below but enough space above, show dropdown on top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
dropdownPosition.value = 'top'
} else {
dropdownPosition.value = 'bottom'
}
})
}
const toggle = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
if (isOpen.value && props.searchable) {
nextTick(() => {
searchInputRef.value?.focus()
})
if (isOpen.value) {
calculateDropdownPosition()
if (props.searchable) {
nextTick(() => {
searchInputRef.value?.focus()
})
}
}
}
@@ -275,6 +305,10 @@ onUnmounted(() => {
@apply overflow-hidden;
}
.select-dropdown-top {
@apply bottom-full mb-2 mt-0;
}
.select-search {
@apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700;
@@ -322,6 +356,17 @@ onUnmounted(() => {
.select-dropdown-enter-from,
.select-dropdown-leave-to {
opacity: 0;
}
/* Animation for dropdown opening downward (default) */
.select-dropdown:not(.select-dropdown-top).select-dropdown-enter-from,
.select-dropdown:not(.select-dropdown-top).select-dropdown-leave-to {
transform: translateY(-8px);
}
/* Animation for dropdown opening upward */
.select-dropdown-top.select-dropdown-enter-from,
.select-dropdown-top.select-dropdown-leave-to {
transform: translateY(8px);
}
</style>

View File

@@ -178,17 +178,19 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import subscriptionsAPI from '@/api/subscriptions'
import { useSubscriptionStore } from '@/stores'
import type { UserSubscription } from '@/types'
const { t } = useI18n()
const subscriptionStore = useSubscriptionStore()
const containerRef = ref<HTMLElement | null>(null)
const tooltipOpen = ref(false)
const activeSubscriptions = ref<UserSubscription[]>([])
const loading = ref(false)
const hasActiveSubscriptions = computed(() => activeSubscriptions.value.length > 0)
// Use store data instead of local state
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
const hasActiveSubscriptions = computed(() => subscriptionStore.hasActiveSubscriptions)
const displaySubscriptions = computed(() => {
// Sort by most usage (highest percentage first)
@@ -275,37 +277,18 @@ function handleClickOutside(event: MouseEvent) {
}
}
async function loadSubscriptions() {
try {
loading.value = true
activeSubscriptions.value = await subscriptionsAPI.getActiveSubscriptions()
} catch (error) {
console.error('Failed to load subscriptions:', error)
activeSubscriptions.value = []
} finally {
loading.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
loadSubscriptions()
// Trigger initial fetch if not already loaded
// The actual data loading is handled by App.vue globally
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
console.error('Failed to load subscriptions in SubscriptionProgressMini:', error)
})
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
// Refresh subscriptions periodically (every 5 minutes)
let refreshInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
refreshInterval = setInterval(loadSubscriptions, 5 * 60 * 1000)
})
onBeforeUnmount(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
</script>
<style scoped>

View File

@@ -2,6 +2,7 @@
export { default as DataTable } from './DataTable.vue'
export { default as Pagination } from './Pagination.vue'
export { default as Modal } from './Modal.vue'
export { default as BaseDialog } from './BaseDialog.vue'
export { default as ConfirmDialog } from './ConfirmDialog.vue'
export { default as StatCard } from './StatCard.vue'
export { default as Toast } from './Toast.vue'