EDMS/components/base/AdvancedDataTable.vue
2025-06-05 14:57:08 +08:00

625 lines
18 KiB
Vue

<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { useDesignSystem } from '~/composables/useDesignSystem';
import { useTouchInteractions } from '~/composables/useTouchInteractions';
import LoadingStates from './LoadingStates.vue';
import RsButton from '~/components/RsButton.vue';
const props = defineProps({
// Data
data: {
type: Array,
default: () => []
},
// Column definitions
columns: {
type: Array,
required: true
},
// Loading state
loading: {
type: Boolean,
default: false
},
// Pagination
pagination: {
type: Object,
default: () => ({
page: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
pageSizes: [10, 25, 50, 100]
})
},
// Selection
selectable: {
type: Boolean,
default: false
},
// Sorting
sortable: {
type: Boolean,
default: true
},
// Filtering
filterable: {
type: Boolean,
default: true
},
// Responsive behavior
responsive: {
type: Boolean,
default: true
},
// Row actions
actions: {
type: Array,
default: () => []
},
// Empty state
emptyText: {
type: String,
default: 'No data available'
},
// Table size
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg'].includes(value)
}
});
const emit = defineEmits([
'sort-change', 'filter-change', 'page-change', 'selection-change',
'row-click', 'row-double-click', 'action-click'
]);
// Design system
const { tokens, utils, layouts } = useDesignSystem();
// Table state
const selectedRows = ref(new Set());
const sortConfig = ref({ key: null, direction: 'asc' });
const filters = ref({});
const currentPage = ref(props.pagination.page);
const pageSize = ref(props.pagination.pageSize);
const expandedRows = ref(new Set());
// Mobile state
const isMobile = ref(false);
const showMobileFilters = ref(false);
// Column management
const visibleColumns = ref([]);
const columnWidths = ref({});
// Filter processed data
const processedData = computed(() => {
let result = [...props.data];
// Apply filters
Object.entries(filters.value).forEach(([key, value]) => {
if (value && value.trim()) {
result = result.filter(row => {
const cellValue = row[key];
if (cellValue === null || cellValue === undefined) return false;
return cellValue.toString().toLowerCase().includes(value.toLowerCase());
});
}
});
// Apply sorting
if (sortConfig.value.key) {
result.sort((a, b) => {
const aVal = a[sortConfig.value.key];
const bVal = b[sortConfig.value.key];
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
return sortConfig.value.direction === 'desc' ? -comparison : comparison;
});
}
return result;
});
// Paginated data
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return processedData.value.slice(start, end);
});
// Table classes
const tableClasses = computed(() => {
const sizeMap = {
sm: 'text-sm',
md: 'text-sm',
lg: 'text-base'
};
return utils.composeClasses(
'w-full border-collapse',
sizeMap[props.size],
props.responsive ? 'table-responsive' : ''
);
});
// Handle sorting
const handleSort = (column) => {
if (!column.sortable && !props.sortable) return;
if (sortConfig.value.key === column.key) {
sortConfig.value.direction = sortConfig.value.direction === 'asc' ? 'desc' : 'asc';
} else {
sortConfig.value = { key: column.key, direction: 'asc' };
}
emit('sort-change', sortConfig.value);
};
// Handle filtering
const handleFilter = (column, value) => {
filters.value[column.key] = value;
currentPage.value = 1; // Reset to first page
emit('filter-change', filters.value);
};
// Handle selection
const handleRowSelection = (row, selected) => {
if (selected) {
selectedRows.value.add(row.id);
} else {
selectedRows.value.delete(row.id);
}
emit('selection-change', Array.from(selectedRows.value));
};
const handleSelectAll = (selected) => {
if (selected) {
processedData.value.forEach(row => selectedRows.value.add(row.id));
} else {
selectedRows.value.clear();
}
emit('selection-change', Array.from(selectedRows.value));
};
// Handle pagination
const handlePageChange = (page) => {
currentPage.value = page;
emit('page-change', { page, pageSize: pageSize.value });
};
const handlePageSizeChange = (size) => {
pageSize.value = size;
currentPage.value = 1;
emit('page-change', { page: 1, pageSize: size });
};
// Mobile responsive
const checkMobile = () => {
isMobile.value = window.innerWidth < 768;
};
// Initialize
onMounted(() => {
visibleColumns.value = props.columns.filter(col => !col.hidden);
checkMobile();
window.addEventListener('resize', checkMobile);
});
// Touch interactions for mobile
const { setupTouchInteractions } = useTouchInteractions();
// Utility functions
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
</script>
<template>
<div class="advanced-data-table h-full flex flex-col">
<!-- Mobile Filters Toggle -->
<div v-if="isMobile && filterable" class="mobile-filter-toggle mb-4 flex-shrink-0">
<RsButton
variant="secondary-outline"
size="sm"
@click="showMobileFilters = !showMobileFilters"
class="w-full"
>
<Icon name="mdi:filter" class="w-4 h-4 mr-2" />
{{ showMobileFilters ? 'Hide' : 'Show' }} Filters
</RsButton>
</div>
<!-- Filters Row (Desktop) / Modal (Mobile) -->
<div
v-if="filterable && (!isMobile || showMobileFilters)"
:class="[
'filters-container mb-4 flex-shrink-0',
isMobile ? 'mobile-filters' : 'desktop-filters'
]"
>
<div class="grid gap-4" :class="isMobile ? 'grid-cols-1' : 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'">
<div
v-for="column in visibleColumns.filter(col => col.filterable !== false)"
:key="`filter-${column.key}`"
class="filter-item"
>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ column.title }}
</label>
<input
v-model="filters[column.key]"
@input="handleFilter(column, $event.target.value)"
type="text"
:placeholder="`Filter ${column.title.toLowerCase()}...`"
class="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
</div>
<!-- Table Container - Scrollable -->
<div class="table-container flex-1 overflow-auto min-h-0 border border-gray-200 dark:border-gray-600 rounded-lg">
<!-- Loading State -->
<LoadingStates
v-if="loading"
type="skeleton-table"
:count="pageSize"
class="p-4"
/>
<!-- Table -->
<table v-else :class="tableClasses" class="min-w-full">
<!-- Header -->
<thead class="bg-gray-50 dark:bg-gray-800 sticky top-0 z-10">
<tr>
<!-- Selection Column -->
<th v-if="selectable" class="table-cell-checkbox">
<input
type="checkbox"
:checked="selectedRows.size === processedData.length && processedData.length > 0"
:indeterminate="selectedRows.size > 0 && selectedRows.size < processedData.length"
@change="handleSelectAll($event.target.checked)"
class="rounded border-gray-300 dark:border-gray-600"
/>
</th>
<!-- Data Columns -->
<th
v-for="column in visibleColumns"
:key="`header-${column.key}`"
:class="[
'table-header-cell',
column.sortable !== false && sortable ? 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700' : '',
column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'
]"
:style="{ width: column.width }"
@click="handleSort(column)"
>
<div class="flex items-center space-x-2">
<span>{{ column.title }}</span>
<div v-if="column.sortable !== false && sortable" class="sort-indicators">
<Icon
name="mdi:chevron-up"
:class="[
'w-4 h-4 transition-colors',
sortConfig.key === column.key && sortConfig.direction === 'asc'
? 'text-primary'
: 'text-gray-400'
]"
/>
<Icon
name="mdi:chevron-down"
:class="[
'w-4 h-4 transition-colors -mt-1',
sortConfig.key === column.key && sortConfig.direction === 'desc'
? 'text-primary'
: 'text-gray-400'
]"
/>
</div>
</div>
</th>
<!-- Actions Column -->
<th v-if="actions.length > 0" class="table-header-cell text-center">
Actions
</th>
</tr>
</thead>
<!-- Body -->
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
<!-- Empty State -->
<tr v-if="paginatedData.length === 0">
<td :colspan="visibleColumns.length + (selectable ? 1 : 0) + (actions.length > 0 ? 1 : 0)"
class="table-cell-empty">
<div class="flex flex-col items-center justify-center py-12">
<Icon name="mdi:database-off" class="w-12 h-12 text-gray-400 mb-4" />
<p class="text-gray-500 dark:text-gray-400">{{ emptyText }}</p>
</div>
</td>
</tr>
<!-- Data Rows -->
<tr
v-for="(row, index) in paginatedData"
:key="`row-${row.id || index}`"
:class="[
'table-row hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer',
selectedRows.has(row.id) ? 'bg-primary/5 dark:bg-primary/10' : ''
]"
@click="emit('row-click', row)"
@dblclick="emit('row-double-click', row)"
>
<!-- Selection Cell -->
<td v-if="selectable" class="table-cell-checkbox">
<input
type="checkbox"
:checked="selectedRows.has(row.id)"
@change="handleRowSelection(row, $event.target.checked)"
@click.stop
class="rounded border-gray-300 dark:border-gray-600"
/>
</td>
<!-- Data Cells -->
<td
v-for="column in visibleColumns"
:key="`cell-${row.id || index}-${column.key}`"
:class="[
'table-data-cell',
column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'
]"
>
<!-- Custom Slot -->
<slot
v-if="column.slot"
:name="`column-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
>
{{ row[column.key] }}
</slot>
<!-- Default Cell Content -->
<template v-else>
<!-- Status Badge -->
<span
v-if="column.type === 'status'"
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
row[column.key] === 'active' ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' :
row[column.key] === 'inactive' ? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' :
'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
]"
>
{{ row[column.key] }}
</span>
<!-- Date -->
<span v-else-if="column.type === 'date'" class="text-gray-900 dark:text-gray-100">
{{ row[column.key] ? new Date(row[column.key]).toLocaleDateString() : '-' }}
</span>
<!-- File Size -->
<span v-else-if="column.type === 'filesize'" class="text-gray-900 dark:text-gray-100">
{{ row[column.key] ? formatFileSize(row[column.key]) : '-' }}
</span>
<!-- Default Text -->
<span v-else class="text-gray-900 dark:text-gray-100">
{{ row[column.key] || '-' }}
</span>
</template>
</td>
<!-- Actions Cell -->
<td v-if="actions.length > 0" class="table-cell-actions">
<div class="flex items-center justify-center space-x-2">
<RsButton
v-for="action in actions"
:key="action.key"
:variant="action.variant || 'secondary-outline'"
size="sm"
@click.stop="emit('action-click', action.key, row)"
:title="action.title"
>
<Icon :name="action.icon" class="w-4 h-4" />
</RsButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div
v-if="pagination && processedData.length > 0"
class="pagination-container mt-4 flex-shrink-0 flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0"
>
<!-- Page Size Selector -->
<div v-if="pagination.showSizeChanger" class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300">Show:</span>
<select
v-model="pageSize"
@change="handlePageSizeChange($event.target.value)"
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option v-for="size in pagination.pageSizes" :key="size" :value="size">
{{ size }}
</option>
</select>
<span class="text-sm text-gray-700 dark:text-gray-300">entries</span>
</div>
<!-- Page Info -->
<div class="text-sm text-gray-700 dark:text-gray-300">
Showing {{ ((currentPage - 1) * pageSize) + 1 }} to
{{ Math.min(currentPage * pageSize, processedData.length) }} of
{{ processedData.length }} entries
</div>
<!-- Page Navigation -->
<div class="flex items-center space-x-2">
<RsButton
variant="secondary-outline"
size="sm"
:disabled="currentPage === 1"
@click="handlePageChange(currentPage - 1)"
>
<Icon name="mdi:chevron-left" class="w-4 h-4" />
</RsButton>
<div class="flex items-center space-x-1">
<RsButton
v-for="page in Math.min(Math.ceil(processedData.length / pageSize), 7)"
:key="page"
:variant="page === currentPage ? 'primary' : 'secondary-outline'"
size="sm"
@click="handlePageChange(page)"
class="min-w-[2rem]"
>
{{ page }}
</RsButton>
</div>
<RsButton
variant="secondary-outline"
size="sm"
:disabled="currentPage >= Math.ceil(processedData.length / pageSize)"
@click="handlePageChange(currentPage + 1)"
>
<Icon name="mdi:chevron-right" class="w-4 h-4" />
</RsButton>
</div>
</div>
</div>
</template>
<style scoped>
.advanced-data-table {
@apply h-full min-h-0;
}
.table-header-cell {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider;
}
.table-data-cell {
@apply px-6 py-4 whitespace-nowrap;
}
.table-cell-checkbox {
@apply px-6 py-4 text-center;
}
.table-cell-actions {
@apply px-6 py-4 text-center;
}
.table-cell-empty {
@apply px-6 py-4 text-center;
}
.sort-indicators {
@apply flex flex-col;
}
.mobile-filters {
@apply p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600;
}
.desktop-filters {
@apply p-4 bg-gray-50 dark:bg-gray-800 rounded-lg;
}
/* Responsive table */
@media (max-width: 768px) {
.table-responsive {
@apply text-xs;
}
.table-header-cell,
.table-data-cell {
@apply px-3 py-2;
}
}
/* Enhanced scrollbar for table container */
.table-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-container::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.table-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
border: 2px solid #f1f5f9;
}
.table-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.table-container::-webkit-scrollbar-corner {
background: #f1f5f9;
}
/* Dark mode scrollbar */
.dark .table-container::-webkit-scrollbar-track {
background: #374151;
}
.dark .table-container::-webkit-scrollbar-thumb {
background: #6b7280;
border-color: #374151;
}
.dark .table-container::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
.dark .table-container::-webkit-scrollbar-corner {
background: #374151;
}
/* Sticky header enhancement */
thead th {
position: sticky;
top: 0;
background: inherit;
backdrop-filter: blur(8px);
}
</style>