feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<span class="app-spinner-wrap" :class="{ 'app-spinner-wrap--with-label': label }">
|
||||
<span
|
||||
class="app-spinner"
|
||||
:class="{ 'app-spinner--accent': !color }"
|
||||
:style="spinnerStyle"
|
||||
role="status"
|
||||
:aria-label="label || '加载中'"
|
||||
/>
|
||||
<span v-if="label" class="app-spinner-label">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 转圈直径(px)
|
||||
size: { type: Number, default: 32 },
|
||||
// 边框粗细(px),默认按尺寸自动计算
|
||||
borderWidth: { type: Number, default: 0 },
|
||||
// 可选文字(显示在转圈右侧,不随转圈旋转)
|
||||
label: { type: String, default: '' },
|
||||
// 自定义颜色(如播放器按钮旁的白色),不传则保持主题色
|
||||
color: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const spinnerStyle = computed(() => {
|
||||
const border = props.borderWidth || Math.max(2, Math.round(props.size / 10))
|
||||
const style = {
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
borderWidth: `${border}px`
|
||||
}
|
||||
if (props.color) {
|
||||
style.borderColor = `color-mix(in srgb, ${props.color} 30%, transparent)`
|
||||
style.borderTopColor = props.color
|
||||
}
|
||||
return style
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-spinner-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-spinner {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
animation: app-spinner-rotate 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* 默认主题色,仅未传 color 时生效 */
|
||||
.app-spinner--accent {
|
||||
border-color: var(--color-accent-alpha-20);
|
||||
border-top-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.app-spinner-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes app-spinner-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="border border-panel-bg-dark rounded-2xl overflow-hidden">
|
||||
<button
|
||||
class="w-full flex items-center justify-between px-5 py-4 hover:bg-panel-bg-darkest-50 transition-colors"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="text-xs font-black text-text-muted uppercase tracking-widest">{{ title }}</span>
|
||||
<ChevronDown :class="['w-4 h-4 text-text-tertiary transition-transform duration-200', open && 'rotate-180']" />
|
||||
</button>
|
||||
<div v-show="open" class="px-5 pb-5">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true }
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative" :class="className">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 bg-bg-primary border rounded-lg transition-all select-none"
|
||||
:class="[
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
isOpen
|
||||
? 'border-primary-50 bg-primary-hover-5 shadow-lg'
|
||||
: 'border-border-secondary hover:border-border-tertiary'
|
||||
]"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span
|
||||
v-if="label"
|
||||
class="text-[8px] font-black uppercase tracking-widest leading-none mb-0.5 transition-colors"
|
||||
:class="isOpen ? 'text-primary' : 'text-text-disabled'"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<span class="text-[11px] font-bold text-text-secondary truncate">{{ displayLabel }}</span>
|
||||
</div>
|
||||
<div class="transition-transform duration-200" :class="{ 'rotate-180': isOpen }">
|
||||
<ChevronDown :size="12" :class="isOpen ? 'text-primary' : 'text-text-secondary'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
:style="dropdownStyle"
|
||||
class="fixed z-[9999] p-1 bg-bg-primary border border-border-secondary rounded-lg shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
<button
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="[
|
||||
isSelected(option)
|
||||
? 'bg-primary-hover-10 text-primary'
|
||||
: 'text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary-40'
|
||||
]"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="truncate">{{ option.label }}</span>
|
||||
<Icon v-if="isSelected(option)" name="check" :size="12" class="shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
modelValue: [String, Number, Object, Array],
|
||||
value: [String, Number, Object, Array],
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
className: String,
|
||||
labelKey: {
|
||||
type: String,
|
||||
default: 'label'
|
||||
},
|
||||
valueKey: {
|
||||
type: String,
|
||||
default: 'value'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:value', 'change'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref(null)
|
||||
const dropdownRef = ref(null)
|
||||
const dropdownStyle = ref({})
|
||||
const { common } = useLocale()
|
||||
const locale = computed(() => common.value || {})
|
||||
const resolvedPlaceholder = computed(() => props.placeholder || locale.value?.selectPlaceholder || '请选择')
|
||||
|
||||
// 统一获取当前值
|
||||
const currentValue = computed(() => {
|
||||
if (props.modelValue !== undefined) return props.modelValue
|
||||
return props.value
|
||||
})
|
||||
|
||||
// 规范化选项为 { label, value } 格式
|
||||
const normalizedOptions = computed(() => {
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return {
|
||||
label: option[props.labelKey] || option.label,
|
||||
value: option[props.valueKey] !== undefined ? option[props.valueKey] : option.value,
|
||||
original: option
|
||||
}
|
||||
}
|
||||
return { label: option, value: option, original: option }
|
||||
})
|
||||
})
|
||||
|
||||
// 获取当前显示标签
|
||||
const displayLabel = computed(() => {
|
||||
if (props.multiple) {
|
||||
const selectedValues = Array.isArray(currentValue.value) ? currentValue.value : []
|
||||
const labels = normalizedOptions.value
|
||||
.filter((option) => selectedValues.includes(option.value))
|
||||
.map((option) => option.label)
|
||||
return labels.length > 0 ? labels.join('、') : props.placeholder
|
||||
}
|
||||
|
||||
const selected = normalizedOptions.value.find((opt) => opt.value === currentValue.value)
|
||||
return selected
|
||||
? selected.label
|
||||
: currentValue.value && typeof currentValue.value !== 'object'
|
||||
? currentValue.value
|
||||
: resolvedPlaceholder.value
|
||||
})
|
||||
|
||||
const isSelected = (option) => {
|
||||
if (props.multiple) {
|
||||
return Array.isArray(currentValue.value) && currentValue.value.includes(option.value)
|
||||
}
|
||||
return option.value === currentValue.value
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!isOpen.value || !containerRef.value || !dropdownRef.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight || 200 // 预估高度
|
||||
|
||||
const windowHeight = window.innerHeight
|
||||
const spaceBelow = windowHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
let top = rect.bottom + 4
|
||||
let transformOrigin = 'origin-top'
|
||||
|
||||
// 如果底部空间不足,并且顶部空间更充足或者能完全放下弹层,则向上展开
|
||||
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
|
||||
top = rect.top - dropdownHeight - 4
|
||||
transformOrigin = 'origin-bottom'
|
||||
}
|
||||
|
||||
dropdownStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${rect.left}px`,
|
||||
width: `${rect.width}px`,
|
||||
minWidth: '120px',
|
||||
transformOrigin
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = async () => {
|
||||
if (props.disabled) return
|
||||
if (isOpen.value) {
|
||||
isOpen.value = false
|
||||
} else {
|
||||
isOpen.value = true
|
||||
// 等待下拉框渲染以便获取其实际高度
|
||||
await nextTick()
|
||||
// 此时元素已存在于 DOM 中,但可能尚未完全重绘,因此先触发一次计算
|
||||
// 并通过 requestAnimationFrame 再次触发,确保计算出正确的高度
|
||||
updatePosition()
|
||||
requestAnimationFrame(updatePosition)
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option) => {
|
||||
if (props.multiple) {
|
||||
const selectedValues = Array.isArray(currentValue.value) ? [...currentValue.value] : []
|
||||
const existingIndex = selectedValues.indexOf(option.value)
|
||||
if (existingIndex >= 0) {
|
||||
selectedValues.splice(existingIndex, 1)
|
||||
} else {
|
||||
selectedValues.push(option.value)
|
||||
}
|
||||
emit('update:modelValue', selectedValues)
|
||||
emit('update:value', selectedValues)
|
||||
emit('change', selectedValues)
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', option.value)
|
||||
emit('update:value', option.value)
|
||||
emit('change', option.value)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
// 检查点击是否在容器内部或者下拉框内部
|
||||
const isClickInContainer = containerRef.value && containerRef.value.contains(event.target)
|
||||
const isClickInDropdown = dropdownRef.value && dropdownRef.value.contains(event.target)
|
||||
|
||||
if (!isClickInContainer && !isClickInDropdown) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleScrollOrResize = () => {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 isOpen 变化来添加/移除事件监听
|
||||
watch(isOpen, (val) => {
|
||||
if (val) {
|
||||
window.addEventListener('scroll', handleScrollOrResize, true) // capture=true 以捕获子元素的滚动
|
||||
window.addEventListener('resize', handleScrollOrResize)
|
||||
} else {
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--panel-bg-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,661 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 表格工具栏 -->
|
||||
<div
|
||||
v-if="showToolbar"
|
||||
class="flex flex-col sm:flex-row items-center justify-between gap-4 px-1"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="toolbar-left">
|
||||
<div
|
||||
v-if="selectedRows.length > 0"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-primary-hover-10 border border-primary-20 rounded-lg"
|
||||
>
|
||||
<span class="text-[10px] font-black text-primary uppercase tracking-widest"
|
||||
>{{ formatLocale(locale.selectedItems, selectedRows.length) }}</span
|
||||
>
|
||||
<button
|
||||
class="p-0.5 text-primary hover:text-primary transition-colors"
|
||||
@click="$emit('clear-selection')"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 w-full sm:w-auto">
|
||||
<slot name="toolbar-right">
|
||||
<button
|
||||
v-if="refreshable"
|
||||
:disabled="loading"
|
||||
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary hover:text-text-primary text-[10px] font-black uppercase tracking-widest rounded-xl transition-all disabled:opacity-50"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': loading }" />
|
||||
<span>{{ locale.refreshData }}</span>
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格主容器 -->
|
||||
<div
|
||||
class="relative bg-bg-secondary-50 border border-border-secondary rounded-xl overflow-hidden shadow-2xl shadow-[0_25px_50px_var(--shadow-color)]"
|
||||
>
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-bg-primary-60 backdrop-blur-[2px] animate-in fade-in duration-300"
|
||||
>
|
||||
<AppSpinner :size="40" class="mb-4" />
|
||||
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest">{{
|
||||
resolvedLoadingText
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<div class="hidden md:block overflow-x-auto custom-scrollbar">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-border-secondary bg-bg-secondary-50">
|
||||
<th v-if="selectable" class="p-4 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
class="w-4 h-4 rounded border-border-tertiary bg-bg-primary text-primary-hover focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
|
||||
@change="toggleSelectAll"
|
||||
>
|
||||
</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
column.class,
|
||||
'p-4 text-[10px] font-black text-text-disabled uppercase tracking-widest'
|
||||
]"
|
||||
:style="{ width: column.width }"
|
||||
>
|
||||
{{ column.title }}
|
||||
</th>
|
||||
<th
|
||||
v-if="hasActions"
|
||||
class="p-4 text-[10px] font-black text-text-disabled uppercase tracking-widest text-right"
|
||||
>
|
||||
{{ locale.actions }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-panel-bg-dark-50">
|
||||
<tr v-if="!loading && data.length === 0">
|
||||
<td :colspan="totalColumns" class="p-20 text-center">
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center gap-3 text-text-secondary">
|
||||
<Database :size="40" stroke-width="1" />
|
||||
<span class="text-[10px] font-black uppercase tracking-widest"
|
||||
>{{ locale.noData }}</span
|
||||
>
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, index) in data"
|
||||
:key="getRowKey(row, index)"
|
||||
class="group hover:bg-bg-tertiary-30 transition-colors cursor-default"
|
||||
:class="{ 'bg-primary-hover-5': selectedRows.includes(getRowKey(row, index)) }"
|
||||
@click="handleRowClick(row, index)"
|
||||
>
|
||||
<td v-if="selectable" class="p-4" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedRows.includes(getRowKey(row, index))"
|
||||
class="w-4 h-4 rounded border-border-tertiary bg-bg-primary text-primary-hover focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
|
||||
@change="toggleSelectRow(getRowKey(row, index))"
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:class="[column.class, 'p-4 text-xs font-bold text-text-secondary']"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:index="index"
|
||||
:value="getNestedValue(row, column.key)"
|
||||
>
|
||||
{{ formatCellValue(getNestedValue(row, column.key), column) }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="hasActions" class="p-4 text-right" @click.stop>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<slot name="actions" :row="row" :index="index">
|
||||
<button
|
||||
class="p-2 text-text-tertiary hover:text-primary hover:bg-primary-10 rounded-xl transition-all"
|
||||
:title="locale.edit"
|
||||
>
|
||||
<Edit2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-text-tertiary hover:text-error hover:bg-error-10 rounded-xl transition-all"
|
||||
:title="locale.delete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="md:hidden divide-y divide-panel-bg-dark">
|
||||
<div v-if="!loading && data.length === 0" class="p-20 text-center">
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center gap-3 text-text-secondary">
|
||||
<Database :size="40" stroke-width="1" />
|
||||
<span class="text-[10px] font-black uppercase tracking-widest">{{ locale.noData }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-for="(row, index) in data"
|
||||
:key="getRowKey(row, index)"
|
||||
class="p-4 space-y-4 hover:bg-bg-tertiary-30 transition-colors"
|
||||
:class="{ 'bg-primary-hover-5': selectedRows.includes(getRowKey(row, index)) }"
|
||||
@click="handleRowClick(row, index)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="selectable" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedRows.includes(getRowKey(row, index))"
|
||||
class="w-4 h-4 rounded border-border-tertiary bg-bg-primary text-primary-hover focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
|
||||
@change="toggleSelectRow(getRowKey(row, index))"
|
||||
>
|
||||
</div>
|
||||
<slot name="mobile-primary" :row="row" :index="index">
|
||||
<span class="text-sm font-black text-text-primary">{{
|
||||
formatCellValue(getNestedValue(row, columns[0]?.key), columns[0])
|
||||
}}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="hasActions" class="flex items-center gap-1" @click.stop>
|
||||
<slot name="actions" :row="row" :index="index">
|
||||
<button
|
||||
class="p-2 text-text-tertiary hover:text-primary hover:bg-primary-10 rounded-xl transition-all"
|
||||
>
|
||||
<Edit2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-text-tertiary hover:text-error hover:bg-error-10 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-y-3 gap-x-4">
|
||||
<div v-for="column in columns.slice(1)" :key="column.key" class="space-y-1">
|
||||
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest block">{{
|
||||
column.title
|
||||
}}</span>
|
||||
<div class="text-xs font-bold text-text-tertiary">
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:index="index"
|
||||
:value="getNestedValue(row, column.key)"
|
||||
>
|
||||
{{ formatCellValue(getNestedValue(row, column.key), column) }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
Database,
|
||||
Edit2,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
MoreHorizontal
|
||||
} from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true },
|
||||
data: { type: Array, required: true },
|
||||
loading: { type: Boolean, default: false },
|
||||
loadingText: { type: String, default: '' },
|
||||
selectable: { type: Boolean, default: false },
|
||||
selectedRows: { type: Array, default: () => [] },
|
||||
rowKey: { type: [String, Function], default: 'id' },
|
||||
refreshable: { type: Boolean, default: false },
|
||||
showToolbar: { type: Boolean, default: true },
|
||||
hasActions: { type: Boolean, default: true },
|
||||
rowClickable: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:selectedRows', 'row-click', 'refresh', 'clear-selection'])
|
||||
const { common } = useLocale()
|
||||
const locale = computed(() => common.value || {})
|
||||
const resolvedLoadingText = computed(() => props.loadingText || locale.value.loadingData)
|
||||
|
||||
const totalColumns = computed(() => {
|
||||
let count = props.columns.length
|
||||
if (props.selectable) count++
|
||||
if (props.hasActions) count++
|
||||
return count
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
if (props.data.length === 0) return false
|
||||
return props.data.every((row) => props.selectedRows.includes(getRowKey(row)))
|
||||
})
|
||||
|
||||
const getRowKey = (row, index) => {
|
||||
if (typeof props.rowKey === 'function') return props.rowKey(row, index)
|
||||
return row[props.rowKey] || index
|
||||
}
|
||||
|
||||
const getNestedValue = (obj, path) => {
|
||||
if (!path) return undefined
|
||||
return path.split('.').reduce((acc, part) => acc && acc[part], obj)
|
||||
}
|
||||
|
||||
const formatCellValue = (value, column) => {
|
||||
if (column.formatter) return column.formatter(value)
|
||||
if (value === null || value === undefined) return '-'
|
||||
return value
|
||||
}
|
||||
|
||||
const toggleSelectAll = (event) => {
|
||||
const checked = event.target.checked
|
||||
const newSelectedRows = checked
|
||||
? [...new Set([...props.selectedRows, ...props.data.map((row) => getRowKey(row))])]
|
||||
: props.selectedRows.filter((id) => !props.data.map((row) => getRowKey(row)).includes(id))
|
||||
emit('update:selectedRows', newSelectedRows)
|
||||
}
|
||||
|
||||
const toggleSelectRow = (key) => {
|
||||
const newSelectedRows = props.selectedRows.includes(key)
|
||||
? props.selectedRows.filter((id) => id !== key)
|
||||
: [...props.selectedRows, key]
|
||||
emit('update:selectedRows', newSelectedRows)
|
||||
}
|
||||
|
||||
const handleRowClick = (row, index) => {
|
||||
if (props.rowClickable) {
|
||||
emit('row-click', { row, index })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* 表格容器 */
|
||||
.table-container {
|
||||
position: relative;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--mask-50);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-lg);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* 表格头部 */
|
||||
.table-header {
|
||||
display: flex;
|
||||
background: var(--table-header-bg);
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-cell {
|
||||
width: 50px;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
width: 120px;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 表格主体 */
|
||||
.table-body {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: var(--table-row-hover);
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row.selected {
|
||||
background-color: var(--table-selected);
|
||||
}
|
||||
|
||||
.table-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: var(--primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: var(--error);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: var(--error-hover);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xl) var(--spacing-xl);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 移动端卡片布局 */
|
||||
.mobile-cards {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
background: var(--table-row-bg);
|
||||
border: 1px solid var(--table-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.data-card:hover {
|
||||
border-color: var(--border-secondary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.data-card:active {
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.data-card.selected {
|
||||
background-color: var(--table-selected);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.data-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.card-selection {
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-primary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.primary-value {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.card-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--border-tertiary);
|
||||
}
|
||||
|
||||
.card-field:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.table-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 隐藏桌面表格,显示移动端卡片 */
|
||||
.desktop-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-cards {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕设备进一步优化 */
|
||||
@media (max-width: 480px) {
|
||||
.data-card {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.primary-value {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.field-label,
|
||||
.field-value {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<slot v-if="!hasError" />
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center min-h-[400px] p-8 text-center bg-bg-secondary-30 border border-dashed border-border-secondary rounded-3xl animate-in fade-in zoom-in duration-300"
|
||||
>
|
||||
<div class="relative mb-6">
|
||||
<div class="absolute inset-0 blur-2xl bg-error-10 rounded-full" />
|
||||
<div
|
||||
class="relative flex items-center justify-center w-16 h-16 bg-bg-primary border border-error-30 rounded-2xl text-error shadow-xl shadow-[0_20px_25px_var(--shadow-color-deep)]"
|
||||
>
|
||||
<AlertCircle :size="32" stroke-width="1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight mb-2">{{ displayTitle }}</h3>
|
||||
<p class="text-[10px] font-black text-text-tertiary uppercase tracking-widest max-w-xs mb-8">
|
||||
{{ displayMessage }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-4">
|
||||
<button
|
||||
:disabled="retrying"
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-bg-primary border border-border-secondary hover:border-primary-50 text-text-tertiary hover:text-text-primary text-[10px] font-black uppercase tracking-widest rounded-xl transition-all shadow-lg shadow-[0_10px_15px_var(--shadow-color)] disabled:opacity-50"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': retrying }" />
|
||||
<span>{{ retrying ? locale.retrying : locale.retry }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showDetails"
|
||||
class="px-6 py-2.5 bg-bg-secondary-50 border border-border-secondary hover:border-border-tertiary text-text-tertiary hover:text-text-secondary text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
|
||||
@click="toggleDetails"
|
||||
>
|
||||
{{ showErrorDetails ? locale.hideDetails : locale.showDetails }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误详情 -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div v-if="showErrorDetails" class="mt-8 w-full max-w-2xl text-left">
|
||||
<div class="p-4 bg-bg-primary border border-border-secondary rounded-2xl">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-2 h-2 rounded-full bg-error animate-pulse" />
|
||||
<span class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
|
||||
>{{ locale.debugInfo }}</span
|
||||
>
|
||||
</div>
|
||||
<pre
|
||||
class="text-[10px] font-mono text-text-tertiary leading-relaxed overflow-x-auto p-4 bg-bg-primary-30 rounded-xl whitespace-pre-wrap break-all"
|
||||
>{{ errorDetails }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { AlertCircle, RefreshCw } from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { ui } = useLocale()
|
||||
const locale = computed(() => ui.value?.errorBoundary || {})
|
||||
|
||||
interface Props {
|
||||
error?: Error | string | null
|
||||
errorTitle?: string
|
||||
errorMessage?: string
|
||||
showDetails?: boolean
|
||||
onRetry?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
error: null,
|
||||
errorTitle: undefined,
|
||||
errorMessage: undefined,
|
||||
showDetails: false,
|
||||
onRetry: undefined
|
||||
})
|
||||
|
||||
const hasError = computed(() => !!props.error)
|
||||
const displayTitle = computed(() => props.errorTitle || locale.value.defaultTitle)
|
||||
const displayMessage = computed(() => props.errorMessage || locale.value.defaultMessage)
|
||||
const retrying = ref(false)
|
||||
const showErrorDetails = ref(false)
|
||||
|
||||
const errorDetails = computed(() => {
|
||||
if (!props.error) return ''
|
||||
if (typeof props.error === 'string') return props.error
|
||||
return props.error.stack || props.error.message || String(props.error)
|
||||
})
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!props.onRetry || retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
try {
|
||||
await props.onRetry()
|
||||
} catch (error) {
|
||||
console.error(locale.value.retryFailed, error)
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDetails = () => {
|
||||
showErrorDetails.value = !showErrorDetails.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-boundary {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--color-error);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-lighter);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 24px 0;
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(135deg, var(--color-indigo-hover), var(--color-collab-hover));
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, var(--color-indigo), var(--color-collab));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.retry-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.retry-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.details-btn {
|
||||
padding: 10px 16px;
|
||||
background: var(--overlay-10);
|
||||
border: 1px solid var(--overlay-20);
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.details-btn:hover {
|
||||
background: var(--overlay-15);
|
||||
border-color: var(--overlay-30);
|
||||
color: var(--text-primary-lighter);
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: var(--surface-card-bg-medium);
|
||||
border: 1px solid var(--overlay-10);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-details h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-lighter);
|
||||
}
|
||||
|
||||
.error-details pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-state {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.retry-btn,
|
||||
.details-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ label }}</label>
|
||||
<input
|
||||
:value="modelValue"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
class="w-full bg-panel-bg-darkest border border-panel-bg-dark rounded-xl px-4 py-2.5 text-sm text-text-primary-light focus:outline-none focus:border-primary-30 placeholder:text-text-disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
type: { type: String, default: 'text' }
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'fixed inset-0 z-[9999] bg-bg-primary-80 backdrop-blur-md': fullScreen,
|
||||
'min-h-[200px] py-12': !fullScreen
|
||||
}"
|
||||
class="flex flex-col items-center justify-center animate-in fade-in duration-300"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center max-w-md w-full px-6">
|
||||
<!-- 加载动画 -->
|
||||
<div class="mb-8">
|
||||
<!-- 默认圆形加载器 -->
|
||||
<div v-if="spinnerType === 'circle'">
|
||||
<AppSpinner :size="48" />
|
||||
</div>
|
||||
|
||||
<!-- 点状加载器 -->
|
||||
<div v-else-if="spinnerType === 'dots'" class="flex gap-2">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="w-3 h-3 bg-primary rounded-full animate-bounce"
|
||||
:style="{ animationDelay: `${(i - 1) * 0.2}s` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 脉冲加载器 -->
|
||||
<div v-else-if="spinnerType === 'pulse'" class="relative w-12 h-12">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="absolute inset-0 border-2 border-primary rounded-full animate-ping"
|
||||
:style="{ animationDelay: `${(i - 1) * 0.4}s`, animationDuration: '2s' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 条状加载器 -->
|
||||
<div v-else-if="spinnerType === 'bars'" class="flex items-end gap-1.5 h-8">
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
class="w-1.5 bg-primary rounded-full animate-bounce"
|
||||
:style="{
|
||||
animationDelay: `${(i - 1) * 0.1}s`,
|
||||
height: `${40 + Math.random() * 60}%`
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载文本 -->
|
||||
<div class="w-full space-y-2">
|
||||
<h3 v-if="title" class="text-xl font-black text-text-primary tracking-tight">{{ title }}</h3>
|
||||
<p class="text-[10px] font-black text-text-tertiary uppercase tracking-widest">{{ displayMessage }}</p>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div v-if="showProgress" class="mt-8 space-y-2">
|
||||
<div class="h-1.5 w-full bg-bg-secondary rounded-full overflow-hidden border border-border-secondary">
|
||||
<div
|
||||
class="h-full bg-primary transition-all duration-300 shadow-[0_0_10px_var(--primary-50)]"
|
||||
:style="{ width: `${progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<span class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
|
||||
>{{ locale.progress }}</span
|
||||
>
|
||||
<span class="text-[10px] font-black text-primary tracking-widest"
|
||||
>{{ progress }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载步骤 -->
|
||||
<div v-if="steps && steps.length > 0" class="mt-8 space-y-3 text-left">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex items-center gap-3 transition-all duration-300"
|
||||
:class="{
|
||||
'opacity-100': index <= currentStep,
|
||||
'opacity-30': index > currentStep
|
||||
}"
|
||||
>
|
||||
<div class="relative flex items-center justify-center w-6 h-6">
|
||||
<div
|
||||
v-if="index < currentStep"
|
||||
class="flex items-center justify-center w-6 h-6 bg-primary-10 border border-primary-20 rounded-lg"
|
||||
>
|
||||
<Check :size="12" class="text-primary" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="index === currentStep"
|
||||
class="w-6 h-6 flex items-center justify-center"
|
||||
>
|
||||
<AppSpinner :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-6 h-6 bg-bg-secondary border border-border-secondary rounded-lg"
|
||||
>
|
||||
<span class="text-[10px] font-black text-text-disabled">{{ index + 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-black uppercase tracking-widest transition-colors"
|
||||
:class="index === currentStep ? 'text-text-primary' : 'text-text-disabled'"
|
||||
>
|
||||
{{ step }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 取消按钮 -->
|
||||
<button
|
||||
v-if="showCancel && onCancel"
|
||||
class="mt-10 px-6 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary hover:text-text-primary text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Check } from '@lucide/vue'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { ui } = useLocale()
|
||||
const locale = computed(() => ui.value?.loadingState || {})
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
message?: string
|
||||
spinnerType?: 'circle' | 'dots' | 'pulse' | 'bars'
|
||||
fullScreen?: boolean
|
||||
showProgress?: boolean
|
||||
progress?: number
|
||||
steps?: string[]
|
||||
currentStep?: number
|
||||
showCancel?: boolean
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
message: undefined,
|
||||
spinnerType: 'circle',
|
||||
fullScreen: false,
|
||||
showProgress: false,
|
||||
progress: 0,
|
||||
steps: () => [],
|
||||
currentStep: 0,
|
||||
showCancel: false,
|
||||
onCancel: undefined
|
||||
})
|
||||
|
||||
const displayMessage = computed(() => props.message || locale.value.defaultMessage)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 保持必要的动画定义,如果 Tailwind 无法完全覆盖的话 */
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.33);
|
||||
opacity: 0;
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div :class="['border rounded-2xl transition-all', enabled ? 'border-panel-bg-hover bg-panel-bg-darkest-30' : 'border-panel-bg-dark-50 bg-panel-bg-darkest-20']">
|
||||
<!-- 卡片头部 -->
|
||||
<div class="flex items-center justify-between px-5 py-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="text-text-tertiary shrink-0">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p :class="['text-sm font-bold truncate', masterOff ? 'text-text-disabled' : 'text-text-secondary']">{{ name }}</p>
|
||||
<p class="text-[11px] text-text-disabled truncate">{{ desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="enabled"
|
||||
:disabled="masterOff"
|
||||
:class="[enabled ? 'bg-primary' : 'bg-panel-bg-compact', masterOff ? 'opacity-40 cursor-not-allowed' : '']"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ml-3"
|
||||
@click="toggle"
|
||||
>
|
||||
<span :class="enabled ? 'translate-x-4' : 'translate-x-0.5'" class="inline-block h-4 w-4 rounded-full bg-panel-bg-raised shadow-sm transition-transform duration-200 mt-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 展开的配置表单 -->
|
||||
<div v-if="enabled" class="px-5 pb-5 border-t border-panel-bg-dark-50 pt-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
enabled: { type: Boolean, default: false },
|
||||
masterOff: { type: Boolean, default: false },
|
||||
name: { type: String, required: true },
|
||||
desc: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle'])
|
||||
|
||||
const toggle = () => {
|
||||
if (!props.masterOff) {
|
||||
emit('toggle', !props.enabled)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-2 pt-4">
|
||||
<!-- 分页信息 -->
|
||||
<div class="text-[10px] font-black text-text-secondary uppercase tracking-[0.2em] order-2 sm:order-1">
|
||||
{{ formatLocale(locale.info, currentPage, totalPages) }}
|
||||
<span v-if="totalItems !== null"> ({{ formatLocale(locale.totalItems, totalItems, displayItemName) }})</span>
|
||||
</div>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 order-1 sm:order-2">
|
||||
<!-- 首页 -->
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
class="w-10 h-10 rounded-lg border border-border-secondary flex items-center justify-center text-text-secondary hover:text-primary hover:border-primary-30 transition-all disabled:opacity-20 disabled:hover:text-text-secondary disabled:hover:border-border-secondary"
|
||||
:title="locale.first"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<ChevronsLeft :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 上一页 -->
|
||||
<button
|
||||
:disabled="currentPage === 1"
|
||||
class="w-10 h-10 rounded-lg border border-border-secondary flex items-center justify-center text-text-secondary hover:text-primary hover:border-primary-30 transition-all disabled:opacity-20 disabled:hover:text-text-secondary disabled:hover:border-border-secondary"
|
||||
:title="locale.prev"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<ChevronLeft :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 当前页码/按钮组 (移动端隐藏,只显示当前页) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-for="page in displayedPages"
|
||||
:key="page"
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg text-xs font-black transition-all shadow-lg',
|
||||
currentPage === page
|
||||
? 'bg-primary-hover text-text-primary shadow-[var(--primary-glow)]'
|
||||
: 'border border-border-secondary text-text-secondary hover:text-primary hover:border-primary-30 shadow-none'
|
||||
]"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 下一页 -->
|
||||
<button
|
||||
:disabled="currentPage === totalPages"
|
||||
class="w-10 h-10 rounded-lg border border-border-secondary flex items-center justify-center text-text-secondary hover:text-primary hover:border-primary-30 transition-all disabled:opacity-20 disabled:hover:text-text-secondary disabled:hover:border-border-secondary"
|
||||
:title="locale.next"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 尾页 -->
|
||||
<button
|
||||
:disabled="currentPage === totalPages"
|
||||
class="w-10 h-10 rounded-lg border border-border-secondary flex items-center justify-center text-text-secondary hover:text-primary hover:border-primary-30 transition-all disabled:opacity-20 disabled:hover:text-text-secondary disabled:hover:border-border-secondary"
|
||||
:title="locale.last"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<ChevronsRight :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 跳转输入 -->
|
||||
<div class="flex items-center ml-2 pl-2 border-l border-border-secondary">
|
||||
<div class="relative group">
|
||||
<input
|
||||
v-model="jumpPageInput"
|
||||
type="text"
|
||||
class="w-12 h-10 bg-bg-primary border border-border-secondary rounded-lg text-center text-xs font-black text-text-secondary focus:outline-none focus:border-primary-50 transition-all"
|
||||
:placeholder="locale.pagePlaceholder"
|
||||
@keyup.enter="handleJump"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="ml-2 px-3 h-10 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-text-primary text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
|
||||
@click="handleJump"
|
||||
>
|
||||
{{ locale.jump }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight
|
||||
} from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { ui } = useLocale()
|
||||
const locale = computed(() => ui.value?.pagination || {})
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalItems: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
itemName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:currentPage', 'change'])
|
||||
|
||||
const jumpPageInput = ref('')
|
||||
const displayItemName = computed(() => props.itemName || locale.value.defaultItemName)
|
||||
|
||||
// 计算要显示的页码
|
||||
const displayedPages = computed(() => {
|
||||
const pages = []
|
||||
const maxVisible = 3 // 手机端展示较少,桌面端也可以通过这个控制
|
||||
|
||||
let start = Math.max(1, props.currentPage - Math.floor(maxVisible / 2))
|
||||
let end = Math.min(props.totalPages, start + maxVisible - 1)
|
||||
|
||||
if (end - start + 1 < maxVisible) {
|
||||
start = Math.max(1, end - maxVisible + 1)
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
|
||||
emit('update:currentPage', page)
|
||||
emit('change', page)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJump = () => {
|
||||
const page = parseInt(jumpPageInput.value)
|
||||
if (!isNaN(page) && page >= 1 && page <= props.totalPages) {
|
||||
goToPage(page)
|
||||
jumpPageInput.value = ''
|
||||
} else {
|
||||
// 简单的输入验证
|
||||
jumpPageInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前页变化,清空跳转输入
|
||||
watch(() => props.currentPage, () => {
|
||||
jumpPageInput.value = ''
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest">{{ label }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:value="modelValue"
|
||||
:type="show ? 'text' : 'password'"
|
||||
:placeholder="placeholder"
|
||||
class="w-full bg-panel-bg-darkest border border-panel-bg-dark rounded-xl px-4 py-2.5 pr-10 text-sm text-text-primary-light focus:outline-none focus:border-primary-30 placeholder:text-text-disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-disabled hover:text-text-muted transition-colors"
|
||||
@click="show = !show"
|
||||
>
|
||||
<EyeOff v-if="show" class="w-4 h-4" />
|
||||
<Eye v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Eye, EyeOff } from '@lucide/vue'
|
||||
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const show = ref(false)
|
||||
</script>
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative inline-block">
|
||||
<div class="cursor-pointer" @click="toggle">
|
||||
<slot name="trigger" :is-open="isOpen" />
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0 translate-y-2"
|
||||
enter-to-class="transform scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="transform scale-95 opacity-0 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="contentRef"
|
||||
:style="contentStyle"
|
||||
class="fixed z-[10000] bg-bg-secondary-95 backdrop-blur-xl border border-primary-10 rounded-xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<slot name="content" :close="close" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch, onUnmounted, type StyleValue } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom-end' // bottom-start, bottom-end, top-start, top-end
|
||||
},
|
||||
offset: {
|
||||
type: Number,
|
||||
default: 8
|
||||
}
|
||||
})
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const contentStyle = ref<StyleValue>({})
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!isOpen.value || !containerRef.value || !contentRef.value) return
|
||||
|
||||
const triggerRect = containerRef.value.getBoundingClientRect()
|
||||
const contentRect = contentRef.value.getBoundingClientRect()
|
||||
const windowWidth = window.innerWidth
|
||||
const windowHeight = window.innerHeight
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
// Vertical positioning base
|
||||
if (props.placement.startsWith('top')) {
|
||||
top = triggerRect.top - contentRect.height - props.offset
|
||||
} else {
|
||||
top = triggerRect.bottom + props.offset
|
||||
}
|
||||
|
||||
// Horizontal positioning base
|
||||
if (props.placement.endsWith('end')) {
|
||||
left = triggerRect.right - contentRect.width
|
||||
} else {
|
||||
left = triggerRect.left
|
||||
}
|
||||
|
||||
// Boundary checks
|
||||
// Right edge
|
||||
if (left + contentRect.width > windowWidth - 10) {
|
||||
left = windowWidth - contentRect.width - 10
|
||||
}
|
||||
// Left edge
|
||||
if (left < 10) {
|
||||
left = 10
|
||||
}
|
||||
|
||||
// Flip vertical if needed
|
||||
if (props.placement.startsWith('bottom') && top + contentRect.height > windowHeight - 10) {
|
||||
// Try top
|
||||
const topSpace = triggerRect.top
|
||||
if (topSpace > contentRect.height + props.offset) {
|
||||
top = triggerRect.top - contentRect.height - props.offset
|
||||
}
|
||||
} else if (props.placement.startsWith('top') && top < 10) {
|
||||
// Try bottom
|
||||
const bottomSpace = windowHeight - triggerRect.bottom
|
||||
if (bottomSpace > contentRect.height + props.offset) {
|
||||
top = triggerRect.bottom + props.offset
|
||||
}
|
||||
}
|
||||
|
||||
contentStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const toggle = async () => {
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
} else {
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (
|
||||
containerRef.value &&
|
||||
!containerRef.value.contains(target) &&
|
||||
contentRef.value &&
|
||||
!contentRef.value.contains(target)
|
||||
) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const handleScrollOrResize = () => {
|
||||
if (isOpen.value) {
|
||||
updatePosition()
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
if (val) {
|
||||
window.addEventListener('scroll', handleScrollOrResize, true)
|
||||
window.addEventListener('resize', handleScrollOrResize)
|
||||
// Use setTimeout to avoid immediate trigger if the click that opened it bubbles up
|
||||
setTimeout(() => document.addEventListener('mousedown', handleClickOutside), 0)
|
||||
} else {
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true)
|
||||
window.removeEventListener('resize', handleScrollOrResize)
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div class="flex flex-col lg:flex-row items-center gap-4 w-full">
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="relative flex-1 w-full">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Search :size="16" class="text-text-tertiary" />
|
||||
</div>
|
||||
<input
|
||||
:value="searchQuery"
|
||||
type="text"
|
||||
:placeholder="resolvedSearchPlaceholder"
|
||||
class="block w-full pl-11 pr-11 py-2.5 bg-bg-primary border border-border-secondary rounded-2xl text-xs font-bold text-text-primary placeholder:text-text-disabled focus:outline-none focus:border-primary-50 focus:bg-primary-hover-5 transition-all"
|
||||
@input="$emit('update:searchQuery', $event.target.value)"
|
||||
>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
@click="$emit('update:searchQuery', '')"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 过滤器容器 -->
|
||||
<div v-if="filters.length > 0" class="flex flex-wrap items-center gap-3 w-full lg:w-auto">
|
||||
<div v-for="filter in filters" :key="filter.key" class="flex-1 lg:flex-none min-w-[140px]">
|
||||
<!-- 选择框过滤器 - 使用 CustomSelect -->
|
||||
<CustomSelect
|
||||
v-if="filter.type === 'select'"
|
||||
:label="filter.label"
|
||||
:value="getSelectValueLabel(filter)"
|
||||
:options="filter.options.map((o) => o.label)"
|
||||
class="w-full"
|
||||
@change="(val) => updateSelectFilter(filter, val)"
|
||||
/>
|
||||
|
||||
<!-- 日期范围过滤器 -->
|
||||
<div
|
||||
v-else-if="filter.type === 'dateRange'"
|
||||
class="flex items-center gap-2 p-1 bg-bg-primary border border-border-secondary rounded-2xl"
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
:value="filterValues[filter.key]?.start || ''"
|
||||
class="bg-transparent border-none text-[10px] font-bold text-text-secondary focus:ring-0 px-2 py-1 w-28"
|
||||
@input="updateDateRange(filter.key, 'start', $event.target.value)"
|
||||
>
|
||||
<span class="text-text-secondary text-[10px] font-black uppercase">{{ locale.to }}</span>
|
||||
<input
|
||||
type="date"
|
||||
:value="filterValues[filter.key]?.end || ''"
|
||||
class="bg-transparent border-none text-[10px] font-bold text-text-secondary focus:ring-0 px-2 py-1 w-28"
|
||||
@input="updateDateRange(filter.key, 'end', $event.target.value)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 多选过滤器 -->
|
||||
<div v-else-if="filter.type === 'multiSelect'" ref="dropdownRef" class="relative">
|
||||
<button
|
||||
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-bg-primary border border-border-secondary rounded-2xl transition-all hover:border-border-tertiary"
|
||||
:class="{ 'border-primary-50 bg-primary-hover-5': openDropdown === filter.key }"
|
||||
@click="toggleDropdown(filter.key)"
|
||||
>
|
||||
<div class="flex flex-col items-start gap-0.5 overflow-hidden">
|
||||
<span
|
||||
v-if="filter.label"
|
||||
class="text-[9px] font-black text-text-disabled uppercase tracking-widest leading-none"
|
||||
>{{ filter.label }}</span
|
||||
>
|
||||
<span class="text-[11px] font-bold text-text-secondary truncate w-full text-left">{{
|
||||
getMultiSelectLabel(filter)
|
||||
}}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
:size="14"
|
||||
class="text-text-tertiary shrink-0 transition-transform"
|
||||
:class="{ 'rotate-180': openDropdown === filter.key }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="openDropdown === filter.key"
|
||||
class="absolute z-50 mt-2 w-full min-w-[200px] bg-bg-secondary border border-border-secondary rounded-2xl shadow-2xl overflow-hidden py-1"
|
||||
>
|
||||
<div class="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
<label
|
||||
v-for="option in filter.options"
|
||||
:key="option.value"
|
||||
class="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-tertiary cursor-pointer group transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="(filterValues[filter.key] || []).includes(option.value)"
|
||||
class="w-3.5 h-3.5 rounded border-border-tertiary bg-bg-primary text-primary-hover focus:ring-0 focus:ring-offset-0 transition-all"
|
||||
@change="toggleMultiSelectOption(filter.key, option.value)"
|
||||
>
|
||||
<span class="text-xs font-bold text-text-tertiary group-hover:text-text-primary">{{
|
||||
option.label
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="showActions" class="flex items-center gap-2 w-full lg:w-auto">
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
class="flex-1 lg:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-bg-secondary border border-border-secondary hover:border-error-30 hover:bg-error-5 text-text-tertiary hover:text-error text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all"
|
||||
@click="clearAllFilters"
|
||||
>
|
||||
<RotateCcw :size="14" />
|
||||
<span>{{ locale.clearFilters }}</span>
|
||||
</button>
|
||||
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Search, X, ChevronDown, RotateCcw } from '@lucide/vue'
|
||||
import CustomSelect from './CustomSelect.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps({
|
||||
searchQuery: { type: String, default: '' },
|
||||
searchPlaceholder: { type: String, default: '' },
|
||||
filters: { type: Array, default: () => [] },
|
||||
filterValues: { type: Object, default: () => ({}) },
|
||||
showActions: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:searchQuery', 'update:filterValues', 'filter-change'])
|
||||
|
||||
const openDropdown = ref(null)
|
||||
const dropdownRef = ref(null)
|
||||
const { common } = useLocale()
|
||||
const locale = computed(() => common.value || {})
|
||||
const resolvedSearchPlaceholder = computed(() => props.searchPlaceholder || locale.value?.searchPlaceholder || '搜索...')
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return (
|
||||
props.searchQuery ||
|
||||
Object.values(props.filterValues).some((value) => {
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return Object.values(value).some((v) => v)
|
||||
}
|
||||
return value !== '' && value !== null && value !== undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const updateFilter = (key, value) => {
|
||||
const newValues = { ...props.filterValues, [key]: value }
|
||||
emit('update:filterValues', newValues)
|
||||
emit('filter-change', { key, value, allValues: newValues })
|
||||
}
|
||||
|
||||
const updateSelectFilter = (filter, label) => {
|
||||
const option = filter.options.find((o) => o.label === label)
|
||||
if (option) {
|
||||
updateFilter(filter.key, option.value)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectValueLabel = (filter) => {
|
||||
const value = props.filterValues[filter.key]
|
||||
const option = filter.options.find((o) => o.value === value)
|
||||
return option ? option.label : filter.placeholder || locale.value.selectPlaceholder
|
||||
}
|
||||
|
||||
const updateDateRange = (key, type, value) => {
|
||||
const currentRange = props.filterValues[key] || {}
|
||||
const newRange = { ...currentRange, [type]: value }
|
||||
updateFilter(key, newRange)
|
||||
}
|
||||
|
||||
const toggleMultiSelectOption = (filterKey, optionValue) => {
|
||||
const currentValues = props.filterValues[filterKey] || []
|
||||
const newValues = currentValues.includes(optionValue)
|
||||
? currentValues.filter((v) => v !== optionValue)
|
||||
: [...currentValues, optionValue]
|
||||
updateFilter(filterKey, newValues)
|
||||
}
|
||||
|
||||
const toggleDropdown = (key) => {
|
||||
openDropdown.value = openDropdown.value === key ? null : key
|
||||
}
|
||||
|
||||
const getMultiSelectLabel = (filter) => {
|
||||
const selectedValues = props.filterValues[filter.key] || []
|
||||
if (selectedValues.length === 0) return filter.placeholder || locale.value.selectPlaceholder
|
||||
if (selectedValues.length === 1) {
|
||||
const option = filter.options.find((opt) => opt.value === selectedValues[0])
|
||||
return option ? option.label : selectedValues[0]
|
||||
}
|
||||
return formatLocale(locale.value.selectedCount, selectedValues.length)
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
emit('update:searchQuery', '')
|
||||
const clearedValues = {}
|
||||
props.filters.forEach((f) => {
|
||||
if (f.type === 'multiSelect') clearedValues[f.key] = []
|
||||
else if (f.type === 'dateRange') clearedValues[f.key] = { start: '', end: '' }
|
||||
else clearedValues[f.key] = ''
|
||||
})
|
||||
emit('update:filterValues', clearedValues)
|
||||
emit('filter-change', { key: 'all', value: null, allValues: clearedValues })
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
|
||||
openDropdown.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* 搜索部分 */
|
||||
.search-section {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-lg);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-quaternary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg) var(--spacing-md) 48px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.clear-search-btn {
|
||||
position: absolute;
|
||||
right: var(--spacing-md);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-quaternary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.clear-search-btn:hover {
|
||||
background: var(--bg-quaternary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clear-search-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 过滤器部分 */
|
||||
.filters-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.date-input {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-select:focus,
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
/* 日期范围过滤器 */
|
||||
.date-range-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 多选过滤器 */
|
||||
.multi-select-filter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.multi-select-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.multi-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
min-width: 150px;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.multi-select-trigger:hover {
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
.multi-select-trigger svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.multi-select-dropdown.open .multi-select-trigger svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.multi-select-options {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: var(--z-dropdown);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.multi-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.multi-select-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.multi-select-option .checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* 操作部分 */
|
||||
.actions-section {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.search-filter {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date-range-filter {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden p-6 bg-bg-secondary border border-border-secondary rounded-xl group hover:border-primary-30 transition-all duration-300 shadow-xl shadow-[0_20px_25px_var(--shadow-color)]"
|
||||
:class="{ 'opacity-60 grayscale cursor-wait': isLoading }"
|
||||
>
|
||||
<!-- 背景装饰 -->
|
||||
<div
|
||||
class="absolute -right-4 -top-4 w-24 h-24 bg-primary-hover-5 blur-3xl rounded-full group-hover:bg-primary-hover-10 transition-colors"
|
||||
/>
|
||||
|
||||
<div class="flex items-start justify-between relative z-10">
|
||||
<div class="space-y-4 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="p-2.5 rounded-lg bg-bg-primary border border-border-secondary group-hover:border-primary-30 group-hover:bg-primary-hover-5 transition-all duration-300"
|
||||
:class="iconClass"
|
||||
>
|
||||
<component
|
||||
:is="iconComponent"
|
||||
:size="18"
|
||||
class="text-text-tertiary group-hover:text-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-black text-text-disabled uppercase tracking-widest group-hover:text-text-tertiary transition-colors"
|
||||
>{{ label }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h3 class="text-3xl font-black text-text-primary tracking-tight">
|
||||
{{ formattedValue }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="change !== undefined"
|
||||
:class="changeClass"
|
||||
class="flex items-center gap-0.5 text-[10px] font-black uppercase px-1.5 py-0.5 rounded-md border"
|
||||
>
|
||||
<TrendingUp v-if="change > 0" :size="10" />
|
||||
<TrendingDown v-else-if="change < 0" :size="10" />
|
||||
<Minus v-else :size="10" />
|
||||
<span>{{ Math.abs(change) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="text-[10px] font-bold text-text-disabled group-hover:text-text-tertiary transition-colors"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 迷你趋势图 -->
|
||||
<div v-if="trendData && trendData.length > 0" class="w-24 h-12 self-end mb-1">
|
||||
<svg class="w-full h-full drop-shadow-[0_0_8px_var(--primary-20)]" viewBox="0 0 100 20">
|
||||
<polyline
|
||||
:points="trendPoints"
|
||||
:stroke="trendColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
class="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载动画 -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-bg-secondary-40 backdrop-blur-[1px]"
|
||||
>
|
||||
<AppSpinner :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
import {
|
||||
Users,
|
||||
Music,
|
||||
Calendar,
|
||||
Heart,
|
||||
Bell,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
Database,
|
||||
ShieldCheck,
|
||||
Mail,
|
||||
Zap,
|
||||
Clock,
|
||||
BarChart3,
|
||||
MessageSquare
|
||||
} from '@lucide/vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: [String, Number], required: true },
|
||||
icon: { type: String, required: true },
|
||||
iconClass: { type: String, default: '' },
|
||||
change: { type: Number, default: undefined },
|
||||
changeLabel: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
trendData: { type: Array, default: () => [] },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
shouldAnimate: { type: Boolean, default: true },
|
||||
format: { type: String, default: 'number' } // 'number', 'currency', 'percentage'
|
||||
})
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const icons = {
|
||||
users: Users,
|
||||
songs: Music,
|
||||
schedule: Calendar,
|
||||
votes: Heart,
|
||||
notifications: Bell,
|
||||
database: Database,
|
||||
security: ShieldCheck,
|
||||
email: Mail,
|
||||
performance: Zap,
|
||||
time: Clock,
|
||||
chart: BarChart3,
|
||||
messages: MessageSquare
|
||||
}
|
||||
return icons[props.icon] || icons.users
|
||||
})
|
||||
|
||||
const changeClass = computed(() => {
|
||||
if (props.change === undefined) return ''
|
||||
if (props.change > 0) return 'bg-success-10 text-success border-success-20'
|
||||
if (props.change < 0) return 'bg-error-10 text-error border-error-20'
|
||||
return 'bg-bg-tertiary text-text-tertiary border-border-tertiary'
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
if (typeof props.value === 'string') return props.value
|
||||
const num = Number(props.value)
|
||||
if (isNaN(num)) return props.value
|
||||
|
||||
switch (props.format) {
|
||||
case 'currency':
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(num)
|
||||
case 'percentage':
|
||||
return `${num}%`
|
||||
case 'number':
|
||||
default:
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
return num.toLocaleString()
|
||||
}
|
||||
})
|
||||
|
||||
const minTrend = computed(() => Math.min(...(props.trendData || [0])))
|
||||
const maxTrend = computed(() => Math.max(...(props.trendData || [1])))
|
||||
|
||||
const trendPoints = computed(() => {
|
||||
if (!props.trendData || props.trendData.length < 2) return ''
|
||||
const range = maxTrend.value - minTrend.value || 1
|
||||
return props.trendData
|
||||
.map((point, index) => {
|
||||
const x = (index / (props.trendData.length - 1)) * 100
|
||||
const y = 18 - ((point - minTrend.value) / range) * 16
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const trendColor = computed(() => {
|
||||
if (!props.trendData || props.trendData.length < 2) {
|
||||
if (props.change > 0) return 'var(--color-success)' // emerald-500
|
||||
if (props.change < 0) return 'var(--color-error)' // red-500
|
||||
return 'var(--color-accent-light)' // blue-500
|
||||
}
|
||||
|
||||
const first = props.trendData[0]
|
||||
const last = props.trendData[props.trendData.length - 1]
|
||||
|
||||
if (last > first) return 'var(--color-success)' // emerald-500
|
||||
if (last < first) return 'var(--color-error)' // red-500
|
||||
return 'var(--color-accent-light)' // blue-500
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user