mirror of
https://github.com/Vb-Lg/class-history-template.git
synced 2026-11-03 19:56:41 +00:00
feat: initial class history template
This commit is contained in:
commit
92fa0f8f37
33 files changed
+10714
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,600 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #080c17;
|
||||
--panel: rgba(255, 255, 255, .045);
|
||||
--panel-hi: rgba(255, 255, 255, .08);
|
||||
--line: rgba(230, 192, 104, .22);
|
||||
--gold: #e6c068;
|
||||
--gold-soft: #f4e0a8;
|
||||
--blue: #6ea8fe;
|
||||
--text: #e9ecf5;
|
||||
--muted: #96a0b8;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
line-height: 1.8;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(230, 192, 104, .3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ===== 背景 ===== */
|
||||
#stars {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
opacity: .35;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glow.g1 {
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
background: #1b3a7a;
|
||||
top: -180px;
|
||||
left: -160px;
|
||||
}
|
||||
|
||||
.glow.g2 {
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
background: #5a3d1a;
|
||||
bottom: -160px;
|
||||
right: -140px;
|
||||
}
|
||||
|
||||
.glow.g3 {
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
background: #123f45;
|
||||
top: 45%;
|
||||
left: 60%;
|
||||
opacity: .25;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ===== 顶部进度条 ===== */
|
||||
#progress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, var(--gold), var(--blue));
|
||||
z-index: 99;
|
||||
transition: width .1s linear;
|
||||
box-shadow: 0 0 12px rgba(230, 192, 104, .7);
|
||||
}
|
||||
|
||||
/* ===== 顶部导航 ===== */
|
||||
.topbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 28px;
|
||||
background: rgba(8, 12, 23, .62);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .05);
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
letter-spacing: .1em;
|
||||
color: var(--gold);
|
||||
text-decoration: none;
|
||||
padding: 7px 16px;
|
||||
border-radius: 100px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(230, 192, 104, .05);
|
||||
transition: .35s;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
background: var(--gold);
|
||||
color: #151006;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
.nav-tag {
|
||||
font-size: 12px;
|
||||
letter-spacing: .35em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.topbar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.nav-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 通用 ===== */
|
||||
.container {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: opacity .9s cubic-bezier(.2, .7, .2, 1), transform .9s cubic-bezier(.2, .7, .2, 1);
|
||||
}
|
||||
|
||||
.reveal.visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ===== 文章头部 ===== */
|
||||
.post-head {
|
||||
padding: 150px 0 46px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
letter-spacing: .4em;
|
||||
color: var(--gold);
|
||||
margin-bottom: 22px;
|
||||
padding: 8px 22px;
|
||||
border-radius: 100px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(230, 192, 104, .05);
|
||||
animation: fadeDown 1s .05s both;
|
||||
}
|
||||
|
||||
.post-head h1 {
|
||||
font-size: clamp(30px, 6.2vw, 52px);
|
||||
font-weight: 800;
|
||||
letter-spacing: .05em;
|
||||
line-height: 1.25;
|
||||
background: linear-gradient(180deg, #fff 10%, #e8d9a8 60%, #9c7c2e 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: fadeDown 1.1s .18s both;
|
||||
filter: drop-shadow(0 8px 34px rgba(230, 192, 104, .2));
|
||||
}
|
||||
|
||||
.post-sub {
|
||||
margin-top: 20px;
|
||||
font-size: clamp(14px, 2vw, 16.5px);
|
||||
color: var(--muted);
|
||||
letter-spacing: .1em;
|
||||
animation: fadeDown 1.1s .34s both;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12.5px;
|
||||
letter-spacing: .14em;
|
||||
color: #66708a;
|
||||
animation: fadeDown 1.1s .5s both;
|
||||
}
|
||||
|
||||
.post-meta i {
|
||||
font-style: normal;
|
||||
color: rgba(230, 192, 104, .5);
|
||||
}
|
||||
|
||||
.head-line {
|
||||
width: 1px;
|
||||
height: 64px;
|
||||
margin: 46px auto 0;
|
||||
background: linear-gradient(180deg, transparent, var(--gold));
|
||||
animation: fadeDown 1.3s .62s both;
|
||||
}
|
||||
|
||||
.head-line::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
margin: 0 auto;
|
||||
transform: translateY(-2px);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px)
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: .4;
|
||||
transform: translateY(-2px) scale(1)
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px) scale(1.6)
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 封面图 ===== */
|
||||
.cover {
|
||||
margin: 0 auto 56px;
|
||||
max-width: 980px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.cover .ph {
|
||||
aspect-ratio: 21/9;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, .07);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(150deg, rgba(230, 192, 104, .1), rgba(110, 168, 254, .06) 55%, rgba(255, 255, 255, .015));
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
letter-spacing: .2em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover .ph::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 130%, rgba(230, 192, 104, .16), transparent 60%);
|
||||
}
|
||||
|
||||
/* ===== 正文 ===== */
|
||||
.post-body {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.post-body p {
|
||||
font-size: 16.5px;
|
||||
line-height: 2.05;
|
||||
color: #c3cad9;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.post-body h2 {
|
||||
font-size: clamp(19px, 3vw, 23px);
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
margin: 52px 0 18px;
|
||||
padding-left: 18px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-body h2::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: .42em;
|
||||
bottom: .28em;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(180deg, var(--gold), rgba(230, 192, 104, .12));
|
||||
}
|
||||
|
||||
.post-body strong {
|
||||
color: var(--gold-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.post-body blockquote {
|
||||
margin: 30px 0;
|
||||
padding: 20px 26px;
|
||||
background: var(--panel);
|
||||
border-left: 2px solid var(--gold);
|
||||
border-radius: 0 16px 16px 0;
|
||||
color: var(--gold-soft);
|
||||
font-size: 15.5px;
|
||||
line-height: 1.95;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-body blockquote::before {
|
||||
content: '“';
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: -6px;
|
||||
font-size: 56px;
|
||||
color: rgba(230, 192, 104, .14);
|
||||
font-family: Georgia, serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.post-body ul {
|
||||
margin: 22px 0 26px;
|
||||
padding-left: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.post-body li {
|
||||
font-size: 16px;
|
||||
color: #c3cad9;
|
||||
line-height: 1.95;
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.post-body li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: .72em;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.figure {
|
||||
margin: 34px 0;
|
||||
}
|
||||
|
||||
.figure .ph {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, .07);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(150deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, .015));
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
letter-spacing: .16em;
|
||||
transition: .5s;
|
||||
}
|
||||
|
||||
.figure .ph:hover {
|
||||
border-color: var(--line);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.figure figcaption {
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: #66708a;
|
||||
letter-spacing: .12em;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* 分隔符 */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin: 56px 0;
|
||||
color: rgba(230, 192, 104, .5);
|
||||
font-size: 12px;
|
||||
letter-spacing: .4em;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
background: linear-gradient(90deg, transparent, var(--line), transparent);
|
||||
}
|
||||
|
||||
/* ===== 上下篇 ===== */
|
||||
.post-nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 30px;
|
||||
padding-top: 44px;
|
||||
border-top: 1px solid rgba(255, 255, 255, .06);
|
||||
}
|
||||
|
||||
.post-nav a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(255, 255, 255, .07);
|
||||
border-radius: 16px;
|
||||
padding: 20px 22px;
|
||||
transition: .45s cubic-bezier(.2, .7, .2, 1);
|
||||
}
|
||||
|
||||
.post-nav a:hover {
|
||||
background: var(--panel-hi);
|
||||
border-color: var(--line);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 18px 40px -20px rgba(0, 0, 0, .9);
|
||||
}
|
||||
|
||||
.post-nav .label {
|
||||
font-size: 11.5px;
|
||||
letter-spacing: .24em;
|
||||
color: var(--gold);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.post-nav .title {
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
|
||||
.post-nav .next {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width:600px) {
|
||||
.post-nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.post-nav .next {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 页脚 ===== */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 80px 24px 60px;
|
||||
margin-top: 80px;
|
||||
border-top: 1px solid rgba(255, 255, 255, .06);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
footer .sig {
|
||||
font-size: clamp(17px, 3vw, 22px);
|
||||
font-weight: 700;
|
||||
letter-spacing: .14em;
|
||||
background: linear-gradient(100deg, var(--gold-soft), var(--gold));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
footer p {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
|
||||
footer .small {
|
||||
margin-top: 22px;
|
||||
font-size: 12px;
|
||||
color: #4c566b;
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
|
||||
/* 回到顶部 */
|
||||
#top {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
background: rgba(230, 192, 104, .12);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--gold);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: .4s;
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#top.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#top:hover {
|
||||
background: var(--gold);
|
||||
color: #151006;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.hero-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.hero-nav .nav-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13.5px;
|
||||
letter-spacing: .1em;
|
||||
color: var(--gold);
|
||||
text-decoration: none;
|
||||
padding: 7px 16px;
|
||||
border-radius: 100px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(230, 192, 104, .05);
|
||||
transition: .35s;
|
||||
}
|
||||
|
||||
.hero-nav .nav-link:hover {
|
||||
background: var(--gold);
|
||||
color: #151006;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/* ================= 星空背景 ================= */
|
||||
(function () {
|
||||
const canvas = document.getElementById('stars');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let w, h, stars = [], dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
function resize() {
|
||||
w = canvas.width = window.innerWidth * dpr;
|
||||
h = canvas.height = window.innerHeight * dpr;
|
||||
canvas.style.width = window.innerWidth + 'px';
|
||||
canvas.style.height = window.innerHeight + 'px';
|
||||
const count = Math.min(160, Math.floor(window.innerWidth / 9));
|
||||
stars = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
r: (Math.random() * 1.4 + .3) * dpr,
|
||||
a: Math.random() * .6 + .15,
|
||||
s: Math.random() * .0009 + .0003,
|
||||
dy: Math.random() * .05 + .015
|
||||
}));
|
||||
}
|
||||
|
||||
let t = 0;
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
t += 1;
|
||||
for (const s of stars) {
|
||||
const alpha = s.a + Math.sin(t * s.s * 12) * .25;
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,244,214,' + Math.max(0.03, Math.min(1, alpha)) + ')';
|
||||
ctx.fill();
|
||||
s.y += s.dy * dpr;
|
||||
if (s.y > h + 5) { s.y = -5; s.x = Math.random() * w; }
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
draw();
|
||||
})();
|
||||
|
||||
/* ================= 打字机 ================= */
|
||||
(function () {
|
||||
const el = document.getElementById('typed');
|
||||
const lines = [
|
||||
'我们的故事,从那年秋天开始',
|
||||
'三年同窗,一生朋友',
|
||||
'愿此去前程似锦,再相逢依然少年'
|
||||
];
|
||||
let li = 0, ci = 0, deleting = false;
|
||||
|
||||
function tick() {
|
||||
const cur = lines[li];
|
||||
if (!deleting) {
|
||||
ci++;
|
||||
el.textContent = cur.slice(0, ci);
|
||||
if (ci === cur.length) {
|
||||
deleting = true;
|
||||
return setTimeout(tick, 1800);
|
||||
}
|
||||
return setTimeout(tick, 95);
|
||||
} else {
|
||||
ci--;
|
||||
el.textContent = cur.slice(0, ci);
|
||||
if (ci === 0) {
|
||||
deleting = false;
|
||||
li = (li + 1) % lines.length;
|
||||
return setTimeout(tick, 350);
|
||||
}
|
||||
return setTimeout(tick, 40);
|
||||
}
|
||||
}
|
||||
setTimeout(tick, 900);
|
||||
})();
|
||||
|
||||
/* ================= 滚动出现 ================= */
|
||||
(function () {
|
||||
const els = document.querySelectorAll('.reveal');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
els.forEach(e => e.classList.add('visible'));
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const el = entry.target;
|
||||
// 延时随文档顺序单调不减(不能取模:那样第 7 个延时回到 0,
|
||||
// 会先于它上面的元素出现),到第 6 级后不再累加。
|
||||
const idx = Array.from(el.parentElement.children).indexOf(el);
|
||||
const delay = Math.min(idx, 6) * 70;
|
||||
setTimeout(() => el.classList.add('visible'), delay);
|
||||
io.unobserve(el);
|
||||
}
|
||||
});
|
||||
}, { threshold: .12, rootMargin: '0px 0px -60px 0px' });
|
||||
els.forEach(e => io.observe(e));
|
||||
})();
|
||||
|
||||
/* ================= 数字滚动 ================= */
|
||||
(function () {
|
||||
const nums = document.querySelectorAll('[data-count]');
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) return;
|
||||
const el = entry.target;
|
||||
const target = parseInt(el.dataset.count, 10);
|
||||
const suffix = target === 999 ? '+' : '';
|
||||
let start = null;
|
||||
const dur = 1600;
|
||||
function step(ts) {
|
||||
if (!start) start = ts;
|
||||
const p = Math.min((ts - start) / dur, 1);
|
||||
const eased = 1 - Math.pow(1 - p, 3);
|
||||
el.textContent = Math.floor(target * eased) + suffix;
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
else el.textContent = target + suffix;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
io.unobserve(el);
|
||||
});
|
||||
}, { threshold: .5 });
|
||||
nums.forEach(n => io.observe(n));
|
||||
})();
|
||||
|
||||
/* ================= 进度条 + 回顶部 ================= */
|
||||
(function () {
|
||||
const bar = document.getElementById('progress');
|
||||
const top = document.getElementById('top');
|
||||
function onScroll() {
|
||||
const st = window.scrollY || document.documentElement.scrollTop;
|
||||
const max = document.documentElement.scrollHeight - window.innerHeight;
|
||||
bar.style.width = (max > 0 ? (st / max) * 100 : 0) + '%';
|
||||
top.classList.toggle('show', st > 500);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
top.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
||||
})();
|
||||
|
||||
/* ================= 留言墙 ================= */
|
||||
(function () {
|
||||
const API = '/api/messages';
|
||||
|
||||
const list = document.getElementById('msgList');
|
||||
const nameI = document.getElementById('msgName');
|
||||
const textI = document.getElementById('msgText');
|
||||
const btn = document.getElementById('msgSend');
|
||||
|
||||
let sending = false;
|
||||
|
||||
const BTN_TEXT = '留 言';
|
||||
// 跟随页面渲染出的占位符(班级名由 src/site.config.ts 注入),避免在此硬编码
|
||||
const PLACEHOLDER = textI.placeholder || '想说点什么……';
|
||||
|
||||
function setTip(tip) {
|
||||
list.innerHTML = '';
|
||||
const p = document.createElement('p');
|
||||
p.style.cssText = 'color:#66708a;font-size:14px;';
|
||||
p.textContent = tip;
|
||||
list.appendChild(p);
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
list.innerHTML = '';
|
||||
if (!data || !data.length) {
|
||||
setTip('还没有留言,来做第一个吧~');
|
||||
return;
|
||||
}
|
||||
data.forEach((m, i) => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
d.style.animationDelay = Math.min(i * 0.05, 0.4) + 's';
|
||||
|
||||
const who = document.createElement('div');
|
||||
who.className = 'who';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = m.name;
|
||||
const time = document.createElement('time');
|
||||
time.textContent = m.time;
|
||||
who.append(span, time);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
body.textContent = m.text;
|
||||
|
||||
d.append(who, body);
|
||||
list.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setTip('加载中…');
|
||||
try {
|
||||
const res = await fetch(API, { headers: { Accept: 'application/json' } });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || '加载失败');
|
||||
render(data.data);
|
||||
} catch (err) {
|
||||
setTip('留言加载失败,请刷新重试~');
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (sending) return;
|
||||
|
||||
const name = nameI.value.trim();
|
||||
const text = textI.value.trim();
|
||||
|
||||
if (!text) {
|
||||
textI.focus();
|
||||
textI.placeholder = '至少写一句话呀~';
|
||||
setTimeout(() => { textI.placeholder = PLACEHOLDER; }, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
sending = true;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '发送中…';
|
||||
|
||||
try {
|
||||
const res = await fetch(API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, text })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || '发送失败');
|
||||
|
||||
textI.value = '';
|
||||
btn.textContent = BTN_TEXT;
|
||||
btn.disabled = false;
|
||||
sending = false;
|
||||
|
||||
// 直接把新留言插到最前面,不用重新拉全量
|
||||
if (data.data) {
|
||||
const first = list.querySelector('.msg');
|
||||
const d = document.createElement('div');
|
||||
d.className = 'msg';
|
||||
d.style.animationDelay = '0s';
|
||||
|
||||
const who = document.createElement('div');
|
||||
who.className = 'who';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = data.data.name;
|
||||
const time = document.createElement('time');
|
||||
time.textContent = data.data.time;
|
||||
who.append(span, time);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
body.textContent = data.data.text;
|
||||
|
||||
d.append(who, body);
|
||||
|
||||
if (first && first.parentNode === list) list.insertBefore(d, first);
|
||||
else {
|
||||
if (list.querySelector('p')) list.innerHTML = '';
|
||||
list.appendChild(d);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
btn.textContent = err.message && err.message.length <= 10 ? err.message : '发送失败';
|
||||
setTimeout(() => {
|
||||
btn.textContent = BTN_TEXT;
|
||||
btn.disabled = false;
|
||||
}, 1600);
|
||||
sending = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', send);
|
||||
|
||||
textI.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') btn.click();
|
||||
});
|
||||
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,192 @@
|
||||
/* ================= 随机抽取歌曲 ================= */
|
||||
/* 歌单数据由 /music/ 页面以 <script type="application/json" id="songs-data"> 注入 */
|
||||
(function () {
|
||||
const dataEl = document.getElementById('songs-data');
|
||||
const btn = document.getElementById('draw-btn');
|
||||
const dialog = document.getElementById('draw-dialog');
|
||||
if (!dataEl || !btn || !dialog) return;
|
||||
|
||||
let songs = [];
|
||||
try {
|
||||
songs = JSON.parse(dataEl.textContent || '[]');
|
||||
} catch (err) {
|
||||
songs = [];
|
||||
}
|
||||
if (!Array.isArray(songs) || songs.length === 0) {
|
||||
btn.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const announceEl = document.getElementById('draw-announce');
|
||||
const titleEl = document.getElementById('draw-song');
|
||||
const artistEl = document.getElementById('draw-artist');
|
||||
const noteEl = document.getElementById('draw-note');
|
||||
const byEl = document.getElementById('draw-by');
|
||||
const playEl = document.getElementById('draw-play');
|
||||
const againEl = document.getElementById('draw-again');
|
||||
const closeEl = document.getElementById('draw-close');
|
||||
const locateEl = document.getElementById('draw-locate');
|
||||
|
||||
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
let current = -1; // 上次抽中的索引,避免连续重复
|
||||
let rollTimer = null; // 滚动定时器
|
||||
let popTimer = null;
|
||||
let enterTimer = null;
|
||||
let flashTimer = null;
|
||||
|
||||
/* 抽取一个和上次不同的索引(只有一首时允许重复) */
|
||||
function pickIndex() {
|
||||
if (songs.length === 1) return 0;
|
||||
let i = Math.floor(Math.random() * songs.length);
|
||||
while (i === current) i = Math.floor(Math.random() * songs.length);
|
||||
return i;
|
||||
}
|
||||
|
||||
function render(index, done) {
|
||||
const s = songs[index] || {};
|
||||
titleEl.textContent = s.title || '';
|
||||
artistEl.textContent = s.artist || '';
|
||||
noteEl.textContent = s.note || '';
|
||||
noteEl.hidden = !s.note;
|
||||
byEl.textContent = s.author || '';
|
||||
byEl.hidden = !s.author;
|
||||
if (s.link) {
|
||||
playEl.setAttribute('href', s.link);
|
||||
playEl.hidden = false;
|
||||
} else {
|
||||
playEl.removeAttribute('href');
|
||||
playEl.hidden = true;
|
||||
}
|
||||
if (done) dialog.dataset.index = String(index);
|
||||
}
|
||||
|
||||
function stopRoll() {
|
||||
if (rollTimer) {
|
||||
clearTimeout(rollTimer);
|
||||
rollTimer = null;
|
||||
}
|
||||
if (popTimer) {
|
||||
clearTimeout(popTimer);
|
||||
popTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动结束,定格在目标歌曲上 */
|
||||
function settle(index) {
|
||||
stopRoll();
|
||||
current = index;
|
||||
render(index, true);
|
||||
dialog.classList.remove('rolling');
|
||||
if (announceEl) {
|
||||
const s = songs[index] || {};
|
||||
announceEl.textContent = '抽中:' + (s.title || '') + (s.artist ? ' · ' + s.artist : '');
|
||||
}
|
||||
if (!reduce) {
|
||||
dialog.classList.add('pop');
|
||||
popTimer = setTimeout(() => {
|
||||
dialog.classList.remove('pop');
|
||||
popTimer = null;
|
||||
}, 480);
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
stopRoll();
|
||||
const target = pickIndex();
|
||||
dialog.classList.remove('pop');
|
||||
|
||||
if (reduce) {
|
||||
settle(target);
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.classList.add('rolling');
|
||||
const duration = 950;
|
||||
const start = Date.now();
|
||||
|
||||
(function tick() {
|
||||
const elapsed = Date.now() - start;
|
||||
render(Math.floor(Math.random() * songs.length), false);
|
||||
if (elapsed >= duration) {
|
||||
settle(target);
|
||||
return;
|
||||
}
|
||||
// 越接近结束,切换间隔越大,形成"减速"手感
|
||||
const delay = 45 + Math.round((elapsed / duration) * 135);
|
||||
rollTimer = setTimeout(tick, delay);
|
||||
})();
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
if (!dialog.open) {
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
}
|
||||
// 每次打开播放一次登场动画
|
||||
if (!reduce) {
|
||||
dialog.classList.remove('open-in');
|
||||
void dialog.offsetWidth; // 强制重排,让动画可以重复播放
|
||||
dialog.classList.add('open-in');
|
||||
if (enterTimer) clearTimeout(enterTimer);
|
||||
enterTimer = setTimeout(() => {
|
||||
dialog.classList.remove('open-in');
|
||||
enterTimer = null;
|
||||
}, 360);
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
stopRoll();
|
||||
if (enterTimer) {
|
||||
clearTimeout(enterTimer);
|
||||
enterTimer = null;
|
||||
}
|
||||
dialog.classList.remove('open-in');
|
||||
if (typeof dialog.close === 'function') {
|
||||
if (dialog.open) dialog.close();
|
||||
} else {
|
||||
dialog.removeAttribute('open');
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', openDialog);
|
||||
againEl.addEventListener('click', draw);
|
||||
closeEl.addEventListener('click', closeDialog);
|
||||
|
||||
// 点击遮罩关闭
|
||||
dialog.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) closeDialog();
|
||||
});
|
||||
// 原生 dialog 的 Esc 关闭
|
||||
dialog.addEventListener('close', stopRoll);
|
||||
dialog.addEventListener('cancel', stopRoll);
|
||||
|
||||
// 在歌单中查看:关闭弹窗并定位高亮
|
||||
locateEl.addEventListener('click', () => {
|
||||
const index = Number(dialog.dataset.index);
|
||||
closeDialog();
|
||||
if (!Number.isInteger(index)) return;
|
||||
const li = document.querySelector('.song-list .song[data-index="' + index + '"]');
|
||||
if (!li) return;
|
||||
li.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'center' });
|
||||
li.classList.add('flash');
|
||||
if (flashTimer) clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => {
|
||||
li.classList.remove('flash');
|
||||
flashTimer = null;
|
||||
}, 1800);
|
||||
});
|
||||
|
||||
// 键盘快捷键:R 抽一首 / 再抽一首
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'r' && e.key !== 'R') return;
|
||||
if (e.repeat) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const t = e.target;
|
||||
if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
|
||||
e.preventDefault();
|
||||
if (dialog.open) draw();
|
||||
else openDialog();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,60 @@
|
||||
/* ================= 歌单每日随机排序 ================= */
|
||||
/* 顺序只由「日期」决定:同一天所有访客看到的顺序完全一致,
|
||||
北京时间每天 0 点自动换一套(无需重新构建,静态页面也能生效)。 */
|
||||
(function () {
|
||||
const list = document.querySelector('.song-list');
|
||||
if (!list) return;
|
||||
|
||||
const items = Array.from(list.children).filter((el) =>
|
||||
el.classList && el.classList.contains('song')
|
||||
);
|
||||
if (items.length < 2) return;
|
||||
|
||||
// 北京时间(UTC+8)当天,如 2026-10-06
|
||||
const day = new Date(Date.now() + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
// FNV-1a:把日期压成 32 位种子
|
||||
let seed = 0x811c9dc5;
|
||||
for (let i = 0; i < day.length; i++) {
|
||||
seed ^= day.charCodeAt(i);
|
||||
seed = Math.imul(seed, 0x01000193) >>> 0;
|
||||
}
|
||||
|
||||
// mulberry32:同一种子 → 同一串伪随机数(跨浏览器一致)
|
||||
function rand() {
|
||||
seed = (seed + 0x6d2b79f5) >>> 0;
|
||||
let t = seed;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
// Fisher–Yates 洗牌(固定种子,结果可复现)
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
const tmp = items[i];
|
||||
items[i] = items[j];
|
||||
items[j] = tmp;
|
||||
}
|
||||
|
||||
// 按新顺序放回,并重排序号。
|
||||
// 注意:这里不要写 transition-delay。入场错峰统一由 pages.js 在元素
|
||||
// 进入视口时按「当前文档顺序」计算;两处各写一套延时就会互相错开。
|
||||
const frag = document.createDocumentFragment();
|
||||
items.forEach((el, i) => {
|
||||
const no = el.querySelector('.no');
|
||||
if (no) no.textContent = String(i + 1).padStart(2, '0');
|
||||
frag.appendChild(el);
|
||||
});
|
||||
list.appendChild(frag);
|
||||
|
||||
// 提示条上标注当前生效的日期
|
||||
const tip = document.getElementById('shuffle-tip');
|
||||
const dayEl = document.getElementById('shuffle-day');
|
||||
if (tip && dayEl) {
|
||||
dayEl.textContent = day;
|
||||
tip.hidden = false;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
/* ================= 星空背景 ================= */
|
||||
(function () {
|
||||
const canvas = document.getElementById('stars');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let w, h, stars = [], dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
function resize() {
|
||||
w = canvas.width = window.innerWidth * dpr;
|
||||
h = canvas.height = window.innerHeight * dpr;
|
||||
canvas.style.width = window.innerWidth + 'px';
|
||||
canvas.style.height = window.innerHeight + 'px';
|
||||
const count = Math.min(160, Math.floor(window.innerWidth / 9));
|
||||
stars = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
r: (Math.random() * 1.4 + .3) * dpr,
|
||||
a: Math.random() * .6 + .15,
|
||||
s: Math.random() * .0009 + .0003,
|
||||
dy: Math.random() * .05 + .015
|
||||
}));
|
||||
}
|
||||
|
||||
let t = 0;
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
t += 1;
|
||||
for (const s of stars) {
|
||||
const alpha = s.a + Math.sin(t * s.s * 12) * .25;
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,244,214,' + Math.max(0.03, Math.min(1, alpha)) + ')';
|
||||
ctx.fill();
|
||||
s.y += s.dy * dpr;
|
||||
if (s.y > h + 5) { s.y = -5; s.x = Math.random() * w; }
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
draw();
|
||||
})();
|
||||
|
||||
/* ================= 滚动出现 ================= */
|
||||
(function () {
|
||||
const els = document.querySelectorAll('.reveal');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
els.forEach(e => e.classList.add('visible'));
|
||||
return;
|
||||
}
|
||||
const STEP = 70; // 同一批元素之间每级错峰延时
|
||||
const MAX_STEP = 6; // 最多累加到第 6 级,再多就保持同一延时
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const el = entry.target;
|
||||
// 延时只在这里算一次,且必须随「当前文档顺序」单调不减:
|
||||
// 用 idx % 6 会让第 7 个元素的延时回到 0,比第 6 个更先出现。
|
||||
// 歌单会先按当天顺序重排 DOM(music-shuffle.js),所以这里读到的
|
||||
// 位置就是访客看到的位置,入场顺序与列表顺序一致。
|
||||
const idx = Array.from(el.parentElement.children).indexOf(el);
|
||||
const delay = Math.min(idx, MAX_STEP) * STEP;
|
||||
setTimeout(() => el.classList.add('visible'), delay);
|
||||
io.unobserve(el);
|
||||
}
|
||||
});
|
||||
}, { threshold: .1, rootMargin: '0px 0px -60px 0px' });
|
||||
els.forEach(e => io.observe(e));
|
||||
})();
|
||||
|
||||
/* ================= 进度条 + 回顶部 ================= */
|
||||
(function () {
|
||||
const bar = document.getElementById('progress');
|
||||
const top = document.getElementById('top');
|
||||
if (!bar && !top) return;
|
||||
function onScroll() {
|
||||
const st = window.scrollY || document.documentElement.scrollTop;
|
||||
const max = document.documentElement.scrollHeight - window.innerHeight;
|
||||
if (bar) bar.style.width = (max > 0 ? (st / max) * 100 : 0) + '%';
|
||||
if (top) top.classList.toggle('show', st > 500);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
if (top) top.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
|
||||
})();
|
||||
@@ -0,0 +1,3 @@
|
||||
文章引用的静态资源(封面图、照片、音视频等)放在这个目录,正文里以 `/pages/static/文件名` 引用。
|
||||
|
||||
投稿表单上传的封面图也会由后端提交到这个目录(路径见 `functions/api/submit.ts` 的 `MEDIA_DIR`)。
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#171b2b"/>
|
||||
<stop offset="1" stop-color="#0d1020"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="500" fill="url(#g)"/>
|
||||
<text x="600" y="245" text-anchor="middle" font-family="sans-serif" font-size="48" fill="#e6c068">封面占位图</text>
|
||||
<text x="600" y="300" text-anchor="middle" font-family="sans-serif" font-size="22" fill="#96a0b8">把图片放到 public/pages/static/ 后替换本文件</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 623 B |
Reference in new issue
Block a user