feat: initial class history template

This commit is contained in:
hanyixuanten committed 2026-10-25 11:23:17 +00:00
commit 92fa0f8f37
33 files changed
+10714

No files matched your search

+282
View File
@@ -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();
})();
+192
View File
@@ -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();
});
})();
+60
View File
@@ -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;
}
})();
+85
View File
@@ -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' }));
})();