初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
<template>
<div ref="containerRef" class="relative" :class="className">
<div
class="flex items-center gap-2 px-3 py-2 bg-zinc-950 border rounded-lg transition-all cursor-pointer select-none"
:class="[
isOpen
? 'border-blue-500/50 bg-blue-600/5 shadow-lg'
: 'border-zinc-800 hover:border-zinc-700'
]"
@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-blue-400' : 'text-zinc-600'"
>
{{ label }}
</span>
<span class="text-[11px] font-bold text-zinc-300 truncate">{{ displayLabel }}</span>
</div>
<div class="transition-transform duration-200" :class="{ 'rotate-180': isOpen }">
<ChevronDown :size="12" :class="isOpen ? 'text-blue-400' : 'text-zinc-700'" />
</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-[#0c0c0e] border border-zinc-800 rounded-lg shadow-2xl backdrop-blur-xl origin-top"
>
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
<button
v-for="option in normalizedOptions"
:key="option.value"
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-[11px] font-bold transition-all"
:class="[
isSelected(option)
? 'bg-blue-600/10 text-blue-400'
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/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'
const props = defineProps({
label: String,
modelValue: [String, Number, Object],
value: [String, Number, Object],
options: {
type: Array,
required: true
},
className: String,
labelKey: {
type: String,
default: 'label'
},
valueKey: {
type: String,
default: 'value'
},
placeholder: {
type: String,
default: '请选择'
}
})
const emit = defineEmits(['update:modelValue', 'update:value', 'change'])
const isOpen = ref(false)
const containerRef = ref(null)
const dropdownRef = ref(null)
const dropdownStyle = ref({})
// 统一获取当前值
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(() => {
const selected = normalizedOptions.value.find((opt) => opt.value === currentValue.value)
return selected
? selected.label
: currentValue.value && typeof currentValue.value !== 'object'
? currentValue.value
: props.placeholder
})
const isSelected = (option) => {
return option.value === currentValue.value
}
const updatePosition = () => {
if (!isOpen.value || !containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
// 简单的位置计算,默认向下弹出
// 检查底部空间
const windowHeight = window.innerHeight
const spaceBelow = windowHeight - rect.bottom
const top = rect.bottom + 4
// 如果底部空间不足且顶部空间充足,则向上弹出
if (spaceBelow < 220 && rect.top > 220) {
// 暂时保持向下,或者使用 fixed 定位让它尽量可见
}
dropdownStyle.value = {
top: `${top}px`,
left: `${rect.left}px`,
minWidth: `${Math.max(160, rect.width)}px`
}
}
const toggleDropdown = async () => {
if (isOpen.value) {
isOpen.value = false
} else {
isOpen.value = true
await nextTick()
updatePosition()
}
}
const selectOption = (option) => {
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: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
+658
View File
@@ -0,0 +1,658 @@
<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-blue-600/10 border border-blue-500/20 rounded-lg"
>
<span class="text-[10px] font-black text-blue-400 uppercase tracking-widest"
>已选择 {{ selectedRows.length }} 项</span
>
<button
class="p-0.5 text-blue-400 hover:text-blue-300 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-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 hover:text-zinc-200 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>刷新数据</span>
</button>
</slot>
</div>
</div>
<!-- 表格主容器 -->
<div
class="relative bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden shadow-2xl shadow-black/20"
>
<!-- 加载状态 -->
<div
v-if="loading"
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950/60 backdrop-blur-[2px] animate-in fade-in duration-300"
>
<div
class="w-10 h-10 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin mb-4"
/>
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest">{{
loadingText
}}</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-zinc-800 bg-zinc-900/50">
<th v-if="selectable" class="p-4 w-10">
<input
type="checkbox"
:checked="isAllSelected"
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-blue-600 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-zinc-600 uppercase tracking-widest'
]"
:style="{ width: column.width }"
>
{{ column.title }}
</th>
<th
v-if="hasActions"
class="p-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-right"
>
操作
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800/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-zinc-700">
<Database :size="40" stroke-width="1" />
<span class="text-[10px] font-black uppercase tracking-widest"
>暂无数据内容</span
>
</div>
</slot>
</td>
</tr>
<tr
v-for="(row, index) in data"
:key="getRowKey(row, index)"
class="group hover:bg-zinc-800/30 transition-colors cursor-default"
:class="{ 'bg-blue-600/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-zinc-700 bg-zinc-950 text-blue-600 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-zinc-300']"
>
<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-zinc-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-xl transition-all"
title="编辑"
>
<Edit2 :size="14" />
</button>
<button
class="p-2 text-zinc-500 hover:text-red-400 hover:bg-red-400/10 rounded-xl transition-all"
title="删除"
>
<Trash2 :size="14" />
</button>
</slot>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片列表 -->
<div class="md:hidden divide-y divide-zinc-800">
<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-zinc-700">
<Database :size="40" stroke-width="1" />
<span class="text-[10px] font-black uppercase tracking-widest">暂无数据内容</span>
</div>
</slot>
</div>
<div
v-for="(row, index) in data"
:key="getRowKey(row, index)"
class="p-4 space-y-4 hover:bg-zinc-800/30 transition-colors"
:class="{ 'bg-blue-600/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-zinc-700 bg-zinc-950 text-blue-600 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-zinc-100">{{
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-zinc-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-xl transition-all"
>
<Edit2 :size="14" />
</button>
<button
class="p-2 text-zinc-500 hover:text-red-400 hover:bg-red-400/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-zinc-600 uppercase tracking-widest block">{{
column.title
}}</span>
<div class="text-xs font-bold text-zinc-400">
<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'
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 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: rgba(0, 0, 0, 0.5);
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>
+285
View File
@@ -0,0 +1,285 @@
<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-zinc-900/30 border border-dashed border-zinc-800 rounded-3xl animate-in fade-in zoom-in duration-300"
>
<div class="relative mb-6">
<div class="absolute inset-0 blur-2xl bg-red-500/10 rounded-full" />
<div
class="relative flex items-center justify-center w-16 h-16 bg-zinc-950 border border-red-500/30 rounded-2xl text-red-500 shadow-xl shadow-black/40"
>
<AlertCircle :size="32" stroke-width="1.5" />
</div>
</div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight mb-2">{{ errorTitle }}</h3>
<p class="text-[10px] font-black text-zinc-500 uppercase tracking-widest max-w-xs mb-8">
{{ errorMessage }}
</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-zinc-950 border border-zinc-800 hover:border-blue-500/50 text-zinc-400 hover:text-zinc-100 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all shadow-lg shadow-black/20 disabled:opacity-50"
@click="handleRetry"
>
<RefreshCw :size="14" :class="{ 'animate-spin': retrying }" />
<span>{{ retrying ? '正在重试' : '重新尝试' }}</span>
</button>
<button
v-if="showDetails"
class="px-6 py-2.5 bg-zinc-900/50 border border-zinc-800 hover:border-zinc-700 text-zinc-500 hover:text-zinc-300 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
@click="toggleDetails"
>
{{ showErrorDetails ? '隐藏详细信息' : '查看详细信息' }}
</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-zinc-950 border border-zinc-800 rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>系统调试信息</span
>
</div>
<pre
class="text-[10px] font-mono text-zinc-500 leading-relaxed overflow-x-auto p-4 bg-black/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'
interface Props {
error?: Error | string | null
errorTitle?: string
errorMessage?: string
showDetails?: boolean
onRetry?: () => void | Promise<void>
}
const props = withDefaults(defineProps<Props>(), {
error: null,
errorTitle: '加载失败',
errorMessage: '数据加载时发生错误,请稍后重试',
showDetails: false,
onRetry: undefined
})
const hasError = computed(() => !!props.error)
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('重试失败:', 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: #ef4444;
margin-bottom: 16px;
}
.error-icon svg {
width: 100%;
height: 100%;
}
.error-title {
font-size: 20px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 8px 0;
}
.error-message {
font-size: 16px;
color: #94a3b8;
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, #4f46e5, #7c3aed);
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, #6366f1, #8b5cf6);
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: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #94a3b8;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.details-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
color: #f1f5f9;
}
.error-details {
margin-top: 24px;
padding: 16px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
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: #f1f5f9;
}
.error-details pre {
margin: 0;
font-size: 12px;
color: #94a3b8;
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>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div
:class="{
'fixed inset-0 z-[9999] bg-zinc-950/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'" class="relative">
<div
class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
/>
<div class="absolute inset-0 blur-lg bg-blue-500/20 rounded-full animate-pulse" />
</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-blue-500 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-blue-500 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-blue-500 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-zinc-100 tracking-tight">{{ title }}</h3>
<p class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">{{ message }}</p>
<!-- 进度条 -->
<div v-if="showProgress" class="mt-8 space-y-2">
<div class="h-1.5 w-full bg-zinc-900 rounded-full overflow-hidden border border-zinc-800">
<div
class="h-full bg-blue-500 transition-all duration-300 shadow-[0_0_10px_rgba(59,130,246,0.5)]"
:style="{ width: `${progress}%` }"
/>
</div>
<div class="flex justify-between items-center px-1">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>加载进度</span
>
<span class="text-[10px] font-black text-blue-500 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-blue-500/10 border border-blue-500/20 rounded-lg"
>
<Check :size="12" class="text-blue-500" />
</div>
<div
v-else-if="index === currentStep"
class="w-6 h-6 border-2 border-blue-500/20 border-t-blue-500 rounded-lg animate-spin"
/>
<div
v-else
class="flex items-center justify-center w-6 h-6 bg-zinc-900 border border-zinc-800 rounded-lg"
>
<span class="text-[10px] font-black text-zinc-600">{{ index + 1 }}</span>
</div>
</div>
<span
class="text-[10px] font-black uppercase tracking-widest transition-colors"
:class="index === currentStep ? 'text-zinc-200' : 'text-zinc-600'"
>
{{ step }}
</span>
</div>
</div>
</div>
<!-- 取消按钮 -->
<button
v-if="showCancel && onCancel"
class="mt-10 px-6 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 hover:text-zinc-200 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
@click="onCancel"
>
取消加载
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { Check } from '@lucide/vue'
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
}
withDefaults(defineProps<Props>(), {
title: '',
message: '正在加载...',
spinnerType: 'circle',
fullScreen: false,
showProgress: false,
progress: 0,
steps: () => [],
currentStep: 0,
showCancel: false,
onCancel: undefined
})
</script>
<style scoped>
/* 保持必要的动画定义,如果 Tailwind 无法完全覆盖的话 */
@keyframes pulse-ring {
0% {
transform: scale(0.33);
opacity: 0;
}
80%,
100% {
opacity: 0;
}
50% {
opacity: 0.5;
}
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<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-zinc-700 uppercase tracking-[0.2em] order-2 sm:order-1">
第 {{ currentPage }} 页 · 共 {{ totalPages }} 页
<span v-if="totalItems !== null"> (共 {{ totalItems }} {{ itemName }})</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-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
title="首页"
@click="goToPage(1)"
>
<ChevronsLeft :size="18" />
</button>
<!-- 上一页 -->
<button
:disabled="currentPage === 1"
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
title="上一页"
@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-blue-600 text-white shadow-blue-900/20'
: 'border border-zinc-800 text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 shadow-none'
]"
@click="goToPage(page)"
>
{{ page }}
</button>
</div>
<!-- 下一页 -->
<button
:disabled="currentPage === totalPages"
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
title="下一页"
@click="goToPage(currentPage + 1)"
>
<ChevronRight :size="18" />
</button>
<!-- 尾页 -->
<button
:disabled="currentPage === totalPages"
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
title="尾页"
@click="goToPage(totalPages)"
>
<ChevronsRight :size="18" />
</button>
<!-- 跳转输入 -->
<div class="flex items-center ml-2 pl-2 border-l border-zinc-800">
<div class="relative group">
<input
v-model="jumpPageInput"
type="text"
class="w-12 h-10 bg-zinc-950 border border-zinc-800 rounded-lg text-center text-xs font-black text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-all"
placeholder="页"
@keyup.enter="handleJump"
>
</div>
<button
class="ml-2 px-3 h-10 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
@click="handleJump"
>
跳转
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight
} from '@lucide/vue'
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 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>
+155
View File
@@ -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-[#1e1e1e]/95 backdrop-blur-xl border border-white/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>
+469
View File
@@ -0,0 +1,469 @@
<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-zinc-500" />
</div>
<input
:value="searchQuery"
type="text"
:placeholder="searchPlaceholder"
class="block w-full pl-11 pr-11 py-2.5 bg-zinc-950 border border-zinc-800 rounded-2xl text-xs font-bold text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/50 focus:bg-blue-600/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-zinc-500 hover:text-zinc-300 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-zinc-950 border border-zinc-800 rounded-2xl"
>
<input
type="date"
:value="filterValues[filter.key]?.start || ''"
class="bg-transparent border-none text-[10px] font-bold text-zinc-300 focus:ring-0 px-2 py-1 w-28"
@input="updateDateRange(filter.key, 'start', $event.target.value)"
>
<span class="text-zinc-700 text-[10px] font-black uppercase">至</span>
<input
type="date"
:value="filterValues[filter.key]?.end || ''"
class="bg-transparent border-none text-[10px] font-bold text-zinc-300 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-zinc-950 border border-zinc-800 rounded-2xl transition-all hover:border-zinc-700"
:class="{ 'border-blue-500/50 bg-blue-600/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-zinc-600 uppercase tracking-widest leading-none"
>{{ filter.label }}</span
>
<span class="text-[11px] font-bold text-zinc-300 truncate w-full text-left">{{
getMultiSelectLabel(filter)
}}</span>
</div>
<ChevronDown
:size="14"
class="text-zinc-500 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-zinc-900 border border-zinc-800 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-zinc-800 cursor-pointer group transition-colors"
>
<input
type="checkbox"
:checked="(filterValues[filter.key] || []).includes(option.value)"
class="w-3.5 h-3.5 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all"
@change="toggleMultiSelectOption(filter.key, option.value)"
>
<span class="text-xs font-bold text-zinc-400 group-hover:text-zinc-200">{{
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-zinc-900 border border-zinc-800 hover:border-red-500/30 hover:bg-red-500/5 text-zinc-400 hover:text-red-400 text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all"
@click="clearAllFilters"
>
<RotateCcw :size="14" />
<span>清除筛选</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'
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 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 || '请选择'
}
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 || '请选择'
if (selectedValues.length === 1) {
const option = filter.options.find((opt) => opt.value === selectedValues[0])
return option ? option.label : selectedValues[0]
}
return `已选 ${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>
+191
View File
@@ -0,0 +1,191 @@
<template>
<div
class="relative overflow-hidden p-6 bg-zinc-900 border border-zinc-800 rounded-xl group hover:border-blue-500/30 transition-all duration-300 shadow-xl shadow-black/20"
:class="{ 'opacity-60 grayscale cursor-wait': isLoading }"
>
<!-- 背景装饰 -->
<div
class="absolute -right-4 -top-4 w-24 h-24 bg-blue-600/5 blur-3xl rounded-full group-hover:bg-blue-600/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-zinc-950 border border-zinc-800 group-hover:border-blue-500/30 group-hover:bg-blue-600/5 transition-all duration-300"
:class="iconClass"
>
<component
:is="iconComponent"
:size="18"
class="text-zinc-400 group-hover:text-blue-400 transition-colors"
/>
</div>
<span
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest group-hover:text-zinc-500 transition-colors"
>{{ label }}</span
>
</div>
<div class="space-y-1">
<div class="flex items-baseline gap-2">
<h3 class="text-3xl font-black text-zinc-100 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-zinc-600 group-hover:text-zinc-500 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_rgba(59,130,246,0.2)]" 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-zinc-900/40 backdrop-blur-[1px]"
>
<div
class="w-6 h-6 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
/>
</div>
</div>
</template>
<script setup>
import { computed } from '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-emerald-500/10 text-emerald-500 border-emerald-500/20'
if (props.change < 0) return 'bg-red-500/10 text-red-500 border-red-500/20'
return 'bg-zinc-800 text-zinc-500 border-zinc-700'
})
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 '#10b981' // emerald-500
if (props.change < 0) return '#ef4444' // red-500
return '#3b82f6' // blue-500
}
const first = props.trendData[0]
const last = props.trendData[props.trendData.length - 1]
if (last > first) return '#10b981' // emerald-500
if (last < first) return '#ef4444' // red-500
return '#3b82f6' // blue-500
})
</script>
<style scoped></style>