【前端】增加树的loading组件并先用至所有机构树

This commit is contained in:
俞宝山
2026-02-11 01:41:16 +08:00
parent 93779db584
commit 14c5e479eb
9 changed files with 487 additions and 42 deletions

View File

@@ -0,0 +1,52 @@
# XnTreeSkeleton 树形骨架屏组件
树形结构的加载占位动画组件,支持 6 种动画风格和自定义主色调。
## 基本用法
```vue
<!-- 默认脉冲节点风格蓝色主题 -->
<xn-tree-skeleton />
<!-- 指定动画类型 -->
<xn-tree-skeleton type="glow" />
<!-- 自定义主色调 -->
<xn-tree-skeleton type="pulse" color="#722ed1" />
```
## Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `type` | `String` | `'pulse'` | 动画类型,见下方可选值 |
| `color` | `String` | `'#1890ff'` | 主色调(十六进制),影响圆点、光效、波纹等颜色 |
## 动画类型 (type)
| 值 | 名称 | 效果描述 |
|----|------|----------|
| `pulse` | 脉冲节点 | 蓝色圆点逐个出现并脉冲跳动,简洁有活力 |
| `skeleton` | 树形骨架屏 | 灰色骨架条 + 微光扫过shimmer最贴近真实内容 |
| `bounce` | 弹性节点 | 节点带弹性动画逐个弹入,圆形指示器脉冲 |
| `glow` | 呼吸光效 | 骨架节点带呼吸光效 + 光带扫过,高级感强 |
| `ripple` | 波纹扩散 | 居中图标 + 波纹扩散效果,适合极简风格 |
| `folder` | 文件夹骨架 | 带文件夹图标和展开箭头的骨架屏,蓝色主题微光 |
## 在页面中使用
组件通过 `unplugin-vue-components` 自动注册,无需手动 import。
典型用法(配合 `v-if/v-else-if` 条件链):
```vue
<div ref="treeContainerRef" style="height: 100%">
<xn-tree-skeleton v-if="treeLoading && treeData.length === 0" />
<a-tree
v-else-if="treeData.length > 0"
:tree-data="treeData"
:height="treeHeight"
/>
<a-empty v-else :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
```

View File

@@ -0,0 +1,428 @@
<template>
<div class="xn-tree-skeleton" :style="cssVars">
<!-- 脉冲节点 -->
<template v-if="type === 'pulse'">
<div
v-for="(node, i) in treeNodes"
:key="i"
class="sk-node pulse-node"
:style="{ paddingLeft: node.level * 20 + 'px', animationDelay: i * 0.08 + 's' }"
>
<div class="pulse-dot" :style="{ animationDelay: i * 0.15 + 's' }" />
<div class="pulse-line" :style="{ width: node.width }" />
</div>
</template>
<!-- 树形骨架屏 -->
<template v-else-if="type === 'skeleton'">
<div
v-for="(node, i) in treeNodes"
:key="i"
class="sk-node skeleton-node"
:style="{ paddingLeft: node.level * 20 + 'px', animationDelay: i * 0.06 + 's' }"
>
<div class="skeleton-icon" />
<div class="skeleton-bar" :style="{ width: node.width }" />
</div>
</template>
<!-- 弹性节点 -->
<template v-else-if="type === 'bounce'">
<div
v-for="(node, i) in treeNodes"
:key="i"
class="sk-node bounce-node"
:style="{ paddingLeft: node.level * 20 + 'px', animationDelay: i * 0.08 + 's' }"
>
<div class="bounce-circle"><div class="bounce-inner" /></div>
<div class="bounce-label" :style="{ width: node.width }" />
</div>
</template>
<!-- 呼吸光效 -->
<template v-else-if="type === 'glow'">
<div
v-for="(node, i) in treeNodes"
:key="i"
class="sk-node glow-node"
:style="{ paddingLeft: node.level * 20 + 'px', animationDelay: i * 0.05 + 's' }"
>
<div class="glow-icon" :style="{ animationDelay: i * 0.15 + 's' }" />
<div class="glow-bar" :style="{ width: node.width }" />
</div>
</template>
<!-- 波纹扩散 -->
<template v-else-if="type === 'ripple'">
<div class="ripple-container">
<div class="ripple-icon">
<svg
class="ripple-svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<div class="ripple-ring" />
<div class="ripple-ring ripple-ring-2" />
<div class="ripple-ring ripple-ring-3" />
</div>
<div class="ripple-text">加载机构数据中...</div>
</div>
</template>
<!-- 文件夹骨架 -->
<template v-else-if="type === 'folder'">
<div
v-for="(node, i) in treeNodes"
:key="i"
class="sk-node folder-node"
:style="{ paddingLeft: node.level * 20 + 'px', animationDelay: i * 0.07 + 's' }"
>
<div v-if="node.hasChild" class="folder-tri" />
<div v-else style="width: 6px; margin-right: 8px" />
<div class="folder-icon" />
<div class="folder-text" :style="{ width: node.width }" />
</div>
</template>
</div>
</template>
<script setup name="xnTreeSkeleton">
import { computed } from 'vue'
const props = defineProps({
// 动画类型pulse | skeleton | bounce | glow | ripple | folder
type: {
type: String,
default: 'pulse',
validator: (v) => ['pulse', 'skeleton', 'bounce', 'glow', 'ripple', 'folder'].includes(v)
},
// 主色调
color: {
type: String,
default: '#1890ff'
}
})
// 解析颜色为 RGB用于 rgba() 透明度变体
const cssVars = computed(() => {
const hex = props.color.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
return {
'--sk-color': props.color,
'--sk-rgb': `${r}, ${g}, ${b}`
}
})
// 模拟树形层级结构
const treeNodes = [
{ level: 0, width: '70%', hasChild: true },
{ level: 1, width: '60%', hasChild: true },
{ level: 2, width: '50%', hasChild: false },
{ level: 2, width: '55%', hasChild: false },
{ level: 1, width: '65%', hasChild: true },
{ level: 2, width: '45%', hasChild: false },
{ level: 2, width: '58%', hasChild: false },
{ level: 2, width: '40%', hasChild: false },
{ level: 1, width: '62%', hasChild: false },
{ level: 0, width: '68%', hasChild: true },
{ level: 1, width: '52%', hasChild: false },
{ level: 1, width: '48%', hasChild: false },
{ level: 0, width: '72%', hasChild: false }
]
</script>
<style scoped>
.xn-tree-skeleton {
width: 100%;
padding: 12px 8px;
}
.sk-node {
display: flex;
align-items: center;
margin-bottom: 10px;
opacity: 0;
}
/* ===== pulse 脉冲节点 ===== */
.pulse-node {
animation: skFadeIn 0.5s ease forwards;
}
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--sk-color);
margin-right: 10px;
flex-shrink: 0;
animation: skPulse 1.2s ease-in-out infinite;
}
.pulse-line {
height: 10px;
border-radius: 5px;
background: rgba(var(--sk-rgb), 0.15);
}
/* ===== skeleton 树形骨架屏 ===== */
.skeleton-node {
animation: skFadeIn 0.4s ease forwards;
}
.skeleton-icon {
width: 14px;
height: 14px;
border-radius: 3px;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skShimmer 1.5s infinite;
margin-right: 8px;
flex-shrink: 0;
}
.skeleton-bar {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skShimmer 1.5s infinite;
}
/* ===== bounce 弹性节点 ===== */
.bounce-node {
transform: scale(0.8);
animation: skBounceIn 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards;
}
.bounce-circle {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--sk-color);
margin-right: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.bounce-inner {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--sk-color);
animation: skPulse 1.5s ease-in-out infinite;
}
.bounce-label {
height: 11px;
border-radius: 5px;
background: rgba(var(--sk-rgb), 0.08);
}
/* ===== glow 呼吸光效 ===== */
.glow-node {
animation: skFadeIn 0.3s ease forwards;
}
.glow-icon {
width: 16px;
height: 16px;
border-radius: 4px;
background: rgba(var(--sk-rgb), 0.12);
margin-right: 8px;
flex-shrink: 0;
animation: skGlow 2s ease-in-out infinite;
}
.glow-bar {
height: 12px;
border-radius: 6px;
background: #f5f5f5;
position: relative;
overflow: hidden;
}
.glow-bar::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(var(--sk-rgb), 0.15), transparent);
animation: skGlowSweep 2s ease-in-out infinite;
}
/* ===== ripple 波纹扩散 ===== */
.ripple-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 230px;
}
.ripple-icon {
width: 40px;
height: 40px;
position: relative;
margin-bottom: 16px;
}
.ripple-svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
color: var(--sk-color);
}
.ripple-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 2px solid var(--sk-color);
border-radius: 50%;
animation: skRipple 1.5s ease-out infinite;
}
.ripple-ring-2 {
animation-delay: 0.5s;
}
.ripple-ring-3 {
animation-delay: 1s;
}
.ripple-text {
color: var(--sk-color);
font-size: 13px;
animation: skBreathe 1.5s ease-in-out infinite;
}
/* ===== folder 文件夹骨架 ===== */
.folder-node {
transform: translateX(-10px);
animation: skSlideIn 0.4s ease forwards;
}
.folder-tri {
width: 0;
height: 0;
border-left: 6px solid #bfbfbf;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
margin-right: 8px;
flex-shrink: 0;
}
.folder-icon {
width: 16px;
height: 13px;
background: #faad14;
border-radius: 2px;
margin-right: 8px;
flex-shrink: 0;
position: relative;
}
.folder-icon::before {
content: '';
position: absolute;
top: -4px;
left: 0;
width: 8px;
height: 4px;
background: #faad14;
border-radius: 2px 2px 0 0;
}
.folder-text {
height: 11px;
border-radius: 5px;
background: linear-gradient(
90deg,
rgba(var(--sk-rgb), 0.12) 0%,
rgba(var(--sk-rgb), 0.25) 50%,
rgba(var(--sk-rgb), 0.12) 100%
);
background-size: 200% 100%;
animation: skShimmer 2s infinite;
}
/* ===== Keyframes ===== */
@keyframes skFadeIn {
to {
opacity: 1;
}
}
@keyframes skPulse {
0%,
100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.4);
opacity: 1;
}
}
@keyframes skShimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes skBounceIn {
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes skGlow {
0%,
100% {
background: rgba(var(--sk-rgb), 0.12);
box-shadow: 0 0 0 rgba(var(--sk-rgb), 0);
}
50% {
background: rgba(var(--sk-rgb), 0.25);
box-shadow: 0 0 8px rgba(var(--sk-rgb), 0.3);
}
}
@keyframes skGlowSweep {
0% {
left: -100%;
}
50% {
left: 100%;
}
100% {
left: 100%;
}
}
@keyframes skRipple {
0% {
width: 10px;
height: 10px;
opacity: 1;
}
100% {
width: 60px;
height: 60px;
opacity: 0;
}
}
@keyframes skBreathe {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
@keyframes skSlideIn {
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

View File

@@ -2,12 +2,7 @@
<XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0"> <XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0">
<template #left> <template #left>
<div ref="treeContainerRef" style="height: 100%"> <div ref="treeContainerRef" style="height: 100%">
<div <xn-tree-skeleton v-if="treeLoading && treeData.length === 0" />
v-if="treeLoading && treeData.length === 0"
style="display: flex; height: 100%; align-items: center; justify-content: center"
>
<a-spin />
</div>
<a-tree <a-tree
v-else-if="treeData.length > 0" v-else-if="treeData.length > 0"
v-model:expandedKeys="defaultExpandedKeys" v-model:expandedKeys="defaultExpandedKeys"

View File

@@ -2,12 +2,7 @@
<XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0"> <XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0">
<template #left> <template #left>
<div ref="treeContainerRef" style="height: 100%"> <div ref="treeContainerRef" style="height: 100%">
<div <xn-tree-skeleton v-if="treeLoading && treeData.length === 0" />
v-if="treeLoading && treeData.length === 0"
style="display: flex; height: 100%; align-items: center; justify-content: center"
>
<a-spin />
</div>
<a-tree <a-tree
v-else-if="treeData.length > 0" v-else-if="treeData.length > 0"
v-model:expandedKeys="defaultExpandedKeys" v-model:expandedKeys="defaultExpandedKeys"

View File

@@ -2,12 +2,7 @@
<XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0"> <XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0">
<template #left> <template #left>
<div ref="treeContainerRef" style="height: 100%"> <div ref="treeContainerRef" style="height: 100%">
<div <xn-tree-skeleton v-if="treeLoading && treeData.length === 0" />
v-if="treeLoading && treeData.length === 0"
style="display: flex; height: 100%; align-items: center; justify-content: center"
>
<a-spin />
</div>
<a-tree <a-tree
v-else-if="treeData.length > 0" v-else-if="treeData.length > 0"
v-model:expandedKeys="defaultExpandedKeys" v-model:expandedKeys="defaultExpandedKeys"

View File

@@ -2,12 +2,7 @@
<XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0"> <XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0">
<template #left> <template #left>
<div ref="treeContainerRef" style="height: 100%"> <div ref="treeContainerRef" style="height: 100%">
<div <xn-tree-skeleton v-if="treeLoading && treeData.length === 0" />
v-if="treeLoading && treeData.length === 0"
style="display: flex; height: 100%; align-items: center; justify-content: center"
>
<a-spin />
</div>
<a-tree <a-tree
v-else-if="treeData.length > 0" v-else-if="treeData.length > 0"
v-model:expandedKeys="defaultExpandedKeys" v-model:expandedKeys="defaultExpandedKeys"

View File

@@ -2,12 +2,7 @@
<XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0"> <XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0">
<template #left> <template #left>
<div ref="treeContainerRef" style="height: 100%"> <div ref="treeContainerRef" style="height: 100%">
<div <xn-tree-skeleton v-if="treeLoading && treeData.length === 0" />
v-if="treeLoading && treeData.length === 0"
style="display: flex; height: 100%; align-items: center; justify-content: center"
>
<a-spin />
</div>
<a-tree <a-tree
v-else-if="treeData.length > 0" v-else-if="treeData.length > 0"
v-model:expandedKeys="defaultExpandedKeys" v-model:expandedKeys="defaultExpandedKeys"

View File

@@ -2,12 +2,7 @@
<XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0"> <XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0">
<template #left> <template #left>
<div ref="treeContainerRef" style="height: 100%"> <div ref="treeContainerRef" style="height: 100%">
<div <xn-tree-skeleton v-if="treeLoading && treeData.length === 0" />
v-if="treeLoading && treeData.length === 0"
style="display: flex; height: 100%; align-items: center; justify-content: center"
>
<a-spin />
</div>
<a-tree <a-tree
v-else-if="treeData.length > 0" v-else-if="treeData.length > 0"
v-model:expandedKeys="defaultExpandedKeys" v-model:expandedKeys="defaultExpandedKeys"

View File

@@ -2,12 +2,7 @@
<XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0"> <XnResizablePanel direction="row" :initial-size="300" :min-size="200" :max-size="500" :md="0">
<template #left> <template #left>
<div ref="treeContainerRef" style="height: 100%"> <div ref="treeContainerRef" style="height: 100%">
<div <xn-tree-skeleton v-if="treeLoading && treeData.length === 0" />
v-if="treeLoading && treeData.length === 0"
style="display: flex; height: 100%; align-items: center; justify-content: center"
>
<a-spin />
</div>
<a-tree <a-tree
v-else-if="treeData.length > 0" v-else-if="treeData.length > 0"
v-model:expandedKeys="defaultExpandedKeys" v-model:expandedKeys="defaultExpandedKeys"