✅ 一、插件目录(官方结构)

1
2
3
4
5
6
7
8
9
/root/addon/sanhai-loading/
├── package.json
├── template.yaml
├── index.js
├── templates/
│ └── partials/
│ └── loading.html
└── static/
└── sanhai-loading.css

✅ 二、核心文件

package.json

1
2
3
4
5
6
7
8
{
"name": "sanhai-loading",
"version": "1.0.0",
"description": "SanhaiOJ 7 款弹性加载动画 · 后台可切换",
"main": "index.js",
"template": true,
"style": true
}

template.yaml

1
partials/loading.html: ./templates/partials/loading.html

index.js

1
2
3
4
5
module.exports = async function (ctx) {
// 后台配置项(动画类型 + 速度)
ctx.config.set('sanhai_loading_type', 'dots', true);
ctx.config.set('sanhai_loading_speed', 1, true);
};

✅ 三、模板文件(官方路径)

templates/partials/loading.html

1
2
3
<!-- SanhaiOJ 弹性加载动画 · 7 选 1 -->
<div id="sanhai-loading" class="sanhai-loader {{ config.get('sanhai_loading_type', 'dots') }}" style="--speed:{{ config.get('sanhai_loading_speed', 1) }}s"></div>
<link rel="stylesheet" href="{{ url('static', 'sanhai-loading.css') }}">

✅ 四、集中样式(7 款动画)

static/sanhai-loading.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/* ---- 基础覆盖 ---- */
#sanhai-loading{
position:fixed;inset:0;z-index:9999;
display:flex;align-items:center;justify-content:center;
background:#fff; /* 官方白底 */
--speed:1s; /* 后台速度变量 */
}

/* ---- 7 款动画 ---- */
.sanhai-loader.dots{
display:flex;gap:8px;
}
.sanhai-loader.dots::before,
.sanhai-loader.dots::after,
.sanhai-loader.dots{
content:'';width:10px;height:10px;background:#165DFF;border-radius:50%;
animation:dotsJump var(--speed) cubic-bezier(.45,.03,.51,1.4) infinite;
}
.sanhai-loader.dots::before{animation-delay:.1s;}
.sanhai-loader.dots::after{animation-delay:.2s;}
@keyframes dotsJump{0%,80%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-12px) scale(1.2);}}

.sanhai-loader.ring{
width:40px;height:40px;border:4px solid #165DFF;border-top-color:transparent;border-radius:50%;
animation:ringElastic var(--speed) cubic-bezier(.68,-.55,.27,1.55) infinite;
}
@keyframes ringElastic{0%{transform:rotate(0deg) scale(1);}50%{transform:rotate(180deg) scale(1.2);}100%{transform:rotate(360deg) scale(1);}}

.sanhai-loader.bar{
display:flex;gap:4px;height:32px;align-items:flex-end;
}
.sanhai-loader.bar::before,
.sanhai-loader.bar::after,
.sanhai-loader.bar{
content:'';width:6px;height:100%;background:#165DFF;border-radius:3px;
animation:barElastic var(--speed) cubic-bezier(.45,.03,.51,1.4) infinite;
}
.sanhai-loader.bar::before{animation-delay:.1s;}
.sanhai-loader.bar::after{animation-delay:.2s;}
@keyframes barElastic{0%,80%,100%{height:20%;}40%{height:100%;}}

.sanhai-loader.pulse{
width:40px;height:40px;background:#165DFF;border-radius:50%;
animation:pulseElastic var(--speed) cubic-bezier(.68,-.55,.27,1.55) infinite;
}
@keyframes pulseElastic{0%{transform:scale(0.95);opacity:1;}50%{transform:scale(1.3);opacity:0.7;}100%{transform:scale(0.95);opacity:1;}}

.sanhai-loader.orbit{
position:relative;width:40px;height:40px;
}
.sanhai-loader.orbit::before,
.sanhai-loader.orbit::after{
content:'';position:absolute;top:0;left:0;width:100%;height:100%;border:3px solid #165DFF;border-radius:50%;
animation:orbitElastic var(--speed) cubic-bezier(.45,.03,.51,1.4) infinite;
}
.sanhai-loader.orbit::after{animation-delay:.2s;border-color:#14C4D9;}
@keyframes orbitElastic{0%{transform:scale(1);opacity:1;}50%{transform:scale(0.3);opacity:0;}100%{transform:scale(1);opacity:1;}}

.sanhai-loader.text{
font-size:20px;font-weight:600;color:#165DFF;letter-spacing:2px;
animation:textElastic var(--speed) cubic-bezier(.68,-.55,.27,1.55) infinite;
}
@keyframes textElastic{0%,100%{transform:scale(1);}50%{transform:scale(1.15);}}

.sanhai-loader.arc{
width:40px;height:40px;background:transparent;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M24 4 A20 20 0 1 1 24 44' stroke='%23165DFF' stroke-width='4' fill='none' stroke-linecap='round' stroke-dasharray='94.2' stroke-dashoffset='94.2'/%3E%3C/svg%3E");
animation:arcElastic var(--speed) cubic-bezier(.68,-.55,.27,1.55) infinite;
}
@keyframes arcElastic{0%{stroke-dashoffset:94.2;transform:rotate(0deg);}50%{stroke-dashoffset:0;transform:rotate(540deg);}100%{stroke-dashoffset:94.2;transform:rotate(1080deg);}}

✅ 五、启用 & 后台配置

1
2
hydrooj addon add /root/addon/sanhai-loading
pm2 restart hydrooja

进入 管理后台 → 系统 → 配置,新增两项:

说明
sanhai_loading_type dots / ring / bar / pulse / orbit / text / arc 7 款动画可选
sanhai_loading_speed 0.5 ~ 3(秒) 实时调速

刷新任意页面 → 官方 partials/loading.html 已被插件替换,升级无损

适用选择页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SanhaiOJ 加载动画实验室(白底完整)</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root{--speed:1s;--accent:#165DFF;}
*{box-sizing:border-box;margin:0;padding:0;}
body{
min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;
background:#ffffff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
padding:40px 24px;
}
header{font-size:24px;font-weight:600;margin-bottom:24px;color:#165DFF;}

/* 速度滑块 */
.control-bar{margin-bottom:24px;font-size:14px;color:#4e5969;display:flex;align-items:center;gap:8px;}
input[type="range"]{width:160px;cursor:pointer;}
#speedValue{display:inline-block;width:40px;text-align:center;font-weight:600;color:#165DFF;}

/* 容器布局(原排列方式) */
.container{display:flex;gap:24px;flex-wrap:wrap;justify-content:center;}

/* 卡片样式(白底) */
.card{
width:160px;height:160px;border-radius:8px;background:#f7f8fa;border:1px solid #e5e6eb;
display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;
transition:transform .25s ease,box-shadow .25s ease;
}
.card:hover{transform:translateY(-4px);box-shadow:0 4px 12px rgba(22,93,255,.15);}
.label{margin-top:8px;font-size:13px;color:#4e5969;}

/* 动画集合(确保元素可见) */
.dots{display:flex;gap:8px;}
.dots span{width:10px;height:10px;background:#165DFF;border-radius:50%;animation:dotsJump var(--speed) cubic-bezier(.45,.03,.51,1.4) infinite;}
.dots span:nth-child(2){animation-delay:.1s;}
.dots span:nth-child(3){animation-delay:.2s;}
@keyframes dotsJump{0%,80%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-12px) scale(1.2);}}

.ring{width:40px;height:40px;border:4px solid #165DFF;border-top-color:transparent;border-radius:50%;animation:ringElastic var(--speed) cubic-bezier(.68,-.55,.27,1.55) infinite;}
@keyframes ringElastic{0%{transform:rotate(0deg) scale(1);}50%{transform:rotate(180deg) scale(1.2);}100%{transform:rotate(360deg) scale(1);}}

.bar{display:flex;gap:4px;height:32px;align-items:flex-end;}
.bar span{width:6px;height:100%;background:#165DFF;border-radius:3px;animation:barElastic var(--speed) cubic-bezier(.45,.03,.51,1.4) infinite;}
.bar span:nth-child(2){animation-delay:.1s;}
.bar span:nth-child(3){animation-delay:.2s;}
.bar span:nth-child(4){animation-delay:.3s;}
.bar span:nth-child(5){animation-delay:.4s;}
@keyframes barElastic{0%,80%,100%{height:20%;}40%{height:100%;}}

.pulse{width:40px;height:40px;background:#165DFF;border-radius:50%;animation:pulseElastic var(--speed) cubic-bezier(.68,-.55,.27,1.55) infinite;}
@keyframes pulseElastic{0%{transform:scale(0.95);opacity:1;}50%{transform:scale(1.3);opacity:0.7;}100%{transform:scale(0.95);opacity:1;}}

.orbit{position:relative;width:40px;height:40px;}
.orbit::before,.orbit::after{content:'';position:absolute;top:0;left:0;width:100%;height:100%;border:3px solid #165DFF;border-radius:50%;animation:orbitElastic var(--speed) cubic-bezier(.45,.03,.51,1.4) infinite;}
.orbit::after{animation-delay:.2s;border-color:#14C4D9;}
@keyframes orbitElastic{0%{transform:scale(1);opacity:1;}50%{transform:scale(0.3);opacity:0;}100%{transform:scale(1);opacity:1;}}

.text{font-size:20px;font-weight:600;color:#165DFF;letter-spacing:2px;animation:textElastic var(--speed) cubic-bezier(.68,-.55,.27,1.55) infinite;}
@keyframes textElastic{0%,100%{transform:scale(1);}50%{transform:scale(1.15);}}

.arc svg{animation:arcElastic var(--speed) cubic-bezier(.68,-.55,.27,1.55) infinite;}
@keyframes arcElastic{0%{stroke-dashoffset:94.2;transform:rotate(0deg);}50%{stroke-dashoffset:0;transform:rotate(540deg);}100%{stroke-dashoffset:94.2;transform:rotate(1080deg);}}
</style>
<script>
const root=document.documentElement;
const range=document.getElementById('speedRange');
const value=document.getElementById('speedValue');
range.oninput=()=>{
const v=parseFloat(range.value).toFixed(1);
root.style.setProperty('--speed',v+'s');
value.textContent=v;
};
</script>
</head>
<body>
<header>SanhaiOJ 加载动画实验室(白底完整)</header>

<div class="control-bar">
<label for="speedRange">速度:</label>
<input type="range" id="speedRange" min="0.5" max="3" step="0.1" value="1">
<span id="speedValue">1.0</span>
</div>

<div class="container">
<!-- 1. 点跳 -->
<div class="card" data-anim="dots">
<div class="dots"><span></span><span></span><span></span></div>
<span class="label">Dots</span>
</div>
<!-- 2. 圆环 -->
<div class="card" data-anim="ring">
<div class="ring"></div>
<span class="label">Ring</span>
</div>
<!-- 3. 柱状 -->
<div class="card" data-anim="bar">
<div class="bar"><span></span><span></span><span></span><span></span><span></span></div>
<span class="label">Bar</span>
</div>
<!-- 4. 脉冲 -->
<div class="card" data-anim="pulse">
<div class="pulse"></div>
<span class="label">Pulse</span>
</div>
<!-- 5. 轨道 -->
<div class="card" data-anim="orbit">
<div class="orbit"></div>
<span class="label">Orbit</span>
</div>
<!-- 6. 文字 -->
<div class="card" data-anim="text">
<div class="text">SanhaiOJ</div>
<span class="label">Text</span>
</div>
<!-- 7. 弧形回旋 -->
<div class="card" data-anim="arc">
<div class="arc"><svg width="48" height="48" viewBox="0 0 48 48"><path d="M24 4 A20 20 0 1 1 24 44" stroke="#165DFF" stroke-width="4" fill="none" stroke-linecap="round" stroke-dasharray="94.2" stroke-dashoffset="94.2"/></svg></div>
<span class="label">Arc</span>
</div>
</div>

<script>
const root=document.documentElement;
const range=document.getElementById('speedRange');
const value=document.getElementById('speedValue');
range.oninput=()=>{
const v=parseFloat(range.value).toFixed(1);
root.style.setProperty('--speed',v+'s');
value.textContent=v;
};
</script>
</body>
</html>