feat(mobile): unify page states with shared StateBlock component
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<view class="state-block" :class="{ card }">
|
||||
<template v-if="type === 'loading'">
|
||||
<view class="state-skeleton" aria-hidden="true">
|
||||
<view v-for="row in 3" :key="row" class="skeleton-row">
|
||||
<view class="skeleton-avatar"></view>
|
||||
<view class="skeleton-lines">
|
||||
<view class="skeleton-line long"></view>
|
||||
<view class="skeleton-line short"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="state-loading">
|
||||
<view class="state-spinner" aria-hidden="true"></view>
|
||||
<text class="state-loading-text">{{ title || '数据加载中…' }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<view class="state-icon" :class="type" aria-hidden="true">
|
||||
<svg v-if="type === 'empty'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="7" y="3" width="10" height="4" rx="1.2" />
|
||||
<path d="M17 5h1.4A1.6 1.6 0 0 1 20 6.6v12.8a1.6 1.6 0 0 1-1.6 1.6H5.6A1.6 1.6 0 0 1 4 19.4V6.6A1.6 1.6 0 0 1 5.6 5H7" />
|
||||
<circle cx="10.5" cy="12.5" r="2.6" />
|
||||
<path d="m12.6 14.6 3.2 3.2" />
|
||||
</svg>
|
||||
<svg v-else-if="type === 'error'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.5 8.8a15 15 0 0 1 19 0" />
|
||||
<path d="M5.6 12.4a10.5 10.5 0 0 1 12.8 0" />
|
||||
<path d="M8.7 15.9a6 6 0 0 1 4.4-1.7" />
|
||||
<circle cx="17.5" cy="17.5" r="3.4" fill="#ffffff" />
|
||||
<path d="m16 16 3 3m0-3-3 3" stroke="#dc2626" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2.8 4.6 5.6v5.2c0 4.6 3.1 8.9 7.4 10.4 4.3-1.5 7.4-5.8 7.4-10.4V5.6z" />
|
||||
<circle cx="12" cy="10" r="2.2" />
|
||||
<path d="M8.6 16.4a3.9 3.9 0 0 1 6.8 0" />
|
||||
</svg>
|
||||
</view>
|
||||
<text class="state-title">{{ title || defaultTitle }}</text>
|
||||
<text v-if="desc || defaultDesc" class="state-desc">{{ desc || defaultDesc }}</text>
|
||||
<view v-if="actionText || secondaryActionText" class="state-actions">
|
||||
<button v-if="secondaryActionText" class="state-button secondary" @click="$emit('secondary-action')">{{ secondaryActionText }}</button>
|
||||
<button v-if="actionText" class="state-button" :class="{ filled: type === 'access' }" @click="$emit('action')">{{ actionText }}</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
type: 'loading' | 'empty' | 'error' | 'access';
|
||||
title?: string;
|
||||
desc?: string;
|
||||
actionText?: string;
|
||||
secondaryActionText?: string;
|
||||
card?: boolean;
|
||||
}>(), {
|
||||
title: '',
|
||||
desc: '',
|
||||
actionText: '',
|
||||
secondaryActionText: '',
|
||||
card: true
|
||||
});
|
||||
|
||||
defineEmits(['action', 'secondary-action']);
|
||||
|
||||
const defaultTitle = computed(() => {
|
||||
if (props.type === 'error') return '数据加载失败';
|
||||
if (props.type === 'access') return '当前账号无主管权限';
|
||||
if (props.type === 'empty') return '暂无数据';
|
||||
return '';
|
||||
});
|
||||
|
||||
const defaultDesc = computed(() => {
|
||||
if (props.type === 'error') return '请检查网络后重试';
|
||||
if (props.type === 'access') return '团队数据仅对主管或项目经理开放';
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.state-block.card {
|
||||
margin-top: 16px;
|
||||
padding: 30px 18px;
|
||||
border: 1px solid #e5edf7;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.state-block:not(.card) {
|
||||
padding: 22px 8px;
|
||||
}
|
||||
|
||||
.state-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #eef1f5;
|
||||
animation: state-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: #eef1f5;
|
||||
animation: state-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-line.long {
|
||||
width: 68%;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
width: 42%;
|
||||
}
|
||||
|
||||
@keyframes state-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.state-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.state-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #f3c1c5;
|
||||
border-top-color: var(--brand);
|
||||
border-radius: 50%;
|
||||
animation: state-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes state-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.state-loading-text {
|
||||
color: var(--employee-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.state-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #f4f6f9;
|
||||
color: #9aa3ae;
|
||||
}
|
||||
|
||||
.state-icon svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.state-icon.error {
|
||||
color: #8a929c;
|
||||
}
|
||||
|
||||
.state-icon.access {
|
||||
color: #8a929c;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
color: var(--employee-text);
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-desc {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.state-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 108px;
|
||||
min-height: 38px;
|
||||
margin: 0;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 19px;
|
||||
background: #ffffff;
|
||||
color: var(--brand);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.state-button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.state-button.filled {
|
||||
border: 0;
|
||||
background: var(--brand);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.state-button.secondary {
|
||||
border-color: #d7dde5;
|
||||
background: #ffffff;
|
||||
color: #3a3d44;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user