|
|
@@ -0,0 +1,75 @@
|
|
|
+<template>
|
|
|
+ <div class="async-state">
|
|
|
+ <!-- Loading -->
|
|
|
+ <div v-if="loading" class="async-state__skeleton">
|
|
|
+ <div v-for="n in skeletonRows" :key="n" class="async-state__skeleton-row" :style="{ width: skeletonWidths[(n - 1) % skeletonWidths.length] }"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Error -->
|
|
|
+ <div v-else-if="error" class="async-state__error">
|
|
|
+ <div class="async-state__error-icon">
|
|
|
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
|
+ <circle cx="12" cy="12" r="10"/>
|
|
|
+ <line x1="12" y1="8" x2="12" y2="12"/>
|
|
|
+ <line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ <p class="async-state__error-text">{{ errorMessage || '加载失败' }}</p>
|
|
|
+ <el-button v-if="retryable" size="small" @click="$emit('retry')">重试</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Empty -->
|
|
|
+ <div v-else-if="empty" class="async-state__empty">
|
|
|
+ <p class="async-state__empty-text">{{ emptyMessage || '暂无数据' }}</p>
|
|
|
+ <p v-if="emptyHint" class="async-state__empty-hint">{{ emptyHint }}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Content -->
|
|
|
+ <slot v-else />
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+defineProps({
|
|
|
+ loading: { type: Boolean, default: false },
|
|
|
+ error: { type: Boolean, default: false },
|
|
|
+ empty: { type: Boolean, default: false },
|
|
|
+ errorMessage: { type: String, default: '' },
|
|
|
+ emptyMessage: { type: String, default: '' },
|
|
|
+ emptyHint: { type: String, default: '' },
|
|
|
+ retryable: { type: Boolean, default: true },
|
|
|
+ skeletonRows: { type: Number, default: 4 }
|
|
|
+})
|
|
|
+
|
|
|
+defineEmits(['retry'])
|
|
|
+
|
|
|
+const skeletonWidths = ['100%', '75%', '90%', '60%']
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.async-state__skeleton { padding: 16px; }
|
|
|
+.async-state__skeleton-row {
|
|
|
+ height: 14px;
|
|
|
+ background: var(--color-surface-muted, #f3f6f9);
|
|
|
+ border-radius: 4px;
|
|
|
+ margin-bottom: 10px;
|
|
|
+ animation: async-pulse 1.5s ease-in-out infinite;
|
|
|
+}
|
|
|
+@keyframes async-pulse {
|
|
|
+ 0%, 100% { opacity: 1; }
|
|
|
+ 50% { opacity: 0.4; }
|
|
|
+}
|
|
|
+.async-state__error,
|
|
|
+.async-state__empty {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ padding: 48px 16px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.async-state__error-icon { color: var(--color-text-muted, #8a97a8); margin-bottom: 12px; }
|
|
|
+.async-state__error-text { color: var(--color-text-secondary, #5f6b7a); font-size: 13px; margin: 0 0 12px; }
|
|
|
+.async-state__empty-text { color: var(--color-text-muted, #8a97a8); font-size: 13px; margin: 0; }
|
|
|
+.async-state__empty-hint { color: var(--color-text-muted, #8a97a8); font-size: 11px; margin: 6px 0 0; }
|
|
|
+</style>
|