refactor(frontend): UI/UX改进和组件优化
- DataTable组件操作列自适应 - 优化各种Modal弹窗 - 统一API调用方式(AbortSignal) - 添加全局订阅状态管理 - 优化各管理视图的交互和布局 - 修复国际化翻译问题
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user