feat(mobile): unify page states with shared StateBlock component

This commit is contained in:
2026-07-21 01:02:39 +08:00
parent ee28465c30
commit 2198a92bae
14 changed files with 407 additions and 186 deletions
+253
View File
@@ -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>