feat: sync security & SEO hardening from 2910 (keep placeholders)

This commit is contained in:
hanyixuanten committed 2026-10-26 14:43:18 +08:00
1 parent 2c1f508b14
commit 339e5b5639
15 files changed
+253 -128

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@
## 结构速览 ## 结构速览
- `src/site.config.ts`:班级信息占位值,各页面引用 - `src/site.config.ts`:班级信息占位值(`SITE`)与首页各区块文案占位值(`HOME`:简介、人物志、荣誉、记忆墙、打字机句子),各页面引用
- 示例内容(使用者应删除):`src/content/20250101.md`、`src/songs/20250101-example.json`;`public/pages/static/cover-placeholder.svg` 为占位封面 - 示例内容(使用者应删除):`src/content/20250101.md`、`src/songs/20250101-example.json`;`public/pages/static/cover-placeholder.svg` 为占位封面
- `functions/api/submit.ts`:投稿 → GitHub App(cms-bot,见 Wiki)建分支开 PR;`functions/api/messages.js`:留言墙(D1,绑定名 `DB`) - `functions/api/submit.ts`:投稿 → GitHub App(cms-bot,见 Wiki)建分支开 PR;`functions/api/messages.js`:留言墙(D1,绑定名 `DB`)
- 构建:Node ≥ 22.12,`npm run build`;本地 `npm run dev` 没有 `/api/*` - 构建:Node ≥ 22.12,`npm run build`;本地 `npm run dev` 没有 `/api/*`
+2 -4
View File
@@ -44,10 +44,8 @@ npm run dev # http://localhost:4321
## 定制班级信息 ## 定制班级信息
1. **`src/site.config.ts`**:班级名、顶栏 logo、首屏徽章、统计数字,改这一个文件即可。 1. **`src/site.config.ts`**:班级名、顶栏 logo、首屏徽章、统计数字,以及首页各区块文案(`HOME`:班级简介、人物志、荣誉、记忆墙、打字机句子、SEO 描述),改这一个文件即可。
2. **`src/pages/index.astro`**:班级简介、人物志、荣誉、记忆墙的示例文案(搜索 `20XX` 和各区块的中文示例)。 2. 主题色(金色 `#e6c068`)在 `public/css/*.css` 中定义。
3. **`public/js/index.js`**:首屏打字机轮播文案(`lines` 数组)。
4. 主题色(金色 `#e6c068`)在 `public/css/*.css` 中定义。
## 内容格式 ## 内容格式
+3 -18
View File
@@ -17,11 +17,7 @@ function json(data, status = 200) {
status, status,
headers: { headers: {
'Content-Type': 'application/json; charset=utf-8', 'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store', 'Cache-Control': 'no-store'
// 如果前端和接口不同域,可加上 CORS 头;同域可删掉
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
} }
}); });
} }
@@ -53,6 +49,8 @@ const SCHEMA_SQL = [
country TEXT, country TEXT,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
)`, )`,
'CREATE INDEX IF NOT EXISTS idx_messages_ip ON messages(ip)',
'CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at)',
'ALTER TABLE messages ADD COLUMN ua TEXT', 'ALTER TABLE messages ADD COLUMN ua TEXT',
'ALTER TABLE messages ADD COLUMN country TEXT', 'ALTER TABLE messages ADD COLUMN country TEXT',
]; ];
@@ -78,19 +76,6 @@ function ensureSchema(env) {
return schemaPromise; return schemaPromise;
} }
/* ---------- OPTIONS(预检) ---------- */
export async function onRequestOptions() {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400'
}
});
}
/* ---------- GET:拉取列表 ---------- */ /* ---------- GET:拉取列表 ---------- */
export async function onRequestGet({ env }) { export async function onRequestGet({ env }) {
if (!env.DB) return json({ ok: false, error: '数据库未绑定' }, 500); if (!env.DB) return json({ ok: false, error: '数据库未绑定' }, 500);
+35 -11
View File
@@ -131,7 +131,7 @@ function extFromMime(mime: string): string {
if (m.includes('webp')) return 'webp'; if (m.includes('webp')) return 'webp';
if (m.includes('gif')) return 'gif'; if (m.includes('gif')) return 'gif';
if (m.includes('avif')) return 'avif'; if (m.includes('avif')) return 'avif';
if (m.includes('svg')) return 'svg'; // SVG 刻意不支持:内含的 <script> 在同源下会执行,属存储型 XSS
return 'bin'; return 'bin';
} }
@@ -274,7 +274,7 @@ async function handleSongSubmit(
}); });
if (!newCommit) throw new Error('创建 commit 失败'); if (!newCommit) throw new Error('创建 commit 失败');
const branch = `music/${ymd}-${Math.random().toString(36).slice(2, 7)}`; const branch = `music/${ymd}-${crypto.randomUUID().slice(0, 8)}`;
await api(`/repos/${owner}/${repo}/git/refs`, { await api(`/repos/${owner}/${repo}/git/refs`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: newCommit.sha }), body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: newCommit.sha }),
@@ -315,6 +315,11 @@ async function handleSongSubmit(
} }
// ---------- 主处理 ---------- // ---------- 主处理 ----------
// 简单限流:同一 IP 两次投稿的最小间隔(秒)。只存在于当前 isolate 的内存里,
// 属尽力而为;更严格的限流应另配 Cloudflare 的 WAF rate limiting 规则。
const SUBMIT_COOLDOWN = 60;
const lastSubmitAt = new Map<string, number>();
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => { export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const json = (data: unknown, status = 200) => const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { new Response(JSON.stringify(data), {
@@ -324,6 +329,26 @@ export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const meta = requestMeta(request); const meta = requestMeta(request);
const submitIp = meta.ip;
if (submitIp) {
const last = lastSubmitAt.get(submitIp);
const now = Math.floor(Date.now() / 1000);
if (last !== undefined && now - last < SUBMIT_COOLDOWN) {
return json(
{ error: `投稿太频繁啦,请等 ${SUBMIT_COOLDOWN - (now - last)} 秒` },
429,
);
}
lastSubmitAt.set(submitIp, now);
// 只保留最近 1000 个 IP,防 Map 无限增长
if (lastSubmitAt.size > 1000) {
for (const k of lastSubmitAt.keys()) {
lastSubmitAt.delete(k);
if (lastSubmitAt.size <= 500) break;
}
}
}
try { try {
// 1) 解析 multipart // 1) 解析 multipart
const ct = request.headers.get('content-type') || ''; const ct = request.headers.get('content-type') || '';
@@ -384,7 +409,9 @@ export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
if (cover instanceof File && cover.size > 0) { if (cover instanceof File && cover.size > 0) {
if (cover.size > 5 * 1024 * 1024) return json({ error: '封面图超过 5 MB' }, 413); if (cover.size > 5 * 1024 * 1024) return json({ error: '封面图超过 5 MB' }, 413);
if (!cover.type.startsWith('image/')) return json({ error: '封面必须是图片' }, 400); if (!cover.type.startsWith('image/') || cover.type.includes('svg')) {
return json({ error: '封面必须是 PNG/JPG/WebP/GIF/AVIF 图片' }, 400);
}
const ext = extFromMime(cover.type); const ext = extFromMime(cover.type);
const base = `${yyyymmdd(date)}-cover`; const base = `${yyyymmdd(date)}-cover`;
@@ -395,16 +422,13 @@ export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
coverBlob = { content: abToBase64(buf), encoding: 'base64' }; coverBlob = { content: abToBase64(buf), encoding: 'base64' };
} }
// 3) 决定文章文件名(同日冲突 → -1, -2, …) // 3) 决定文章文件名:优先用日期;已被占用(同日已有投稿)时退回
// 日期-随机后缀,避免「先探测再提交」的竞态导致两个并发投稿撞名
let articleName = `${yyyymmdd(date)}.md`; let articleName = `${yyyymmdd(date)}.md`;
let articlePath = `${contentDir}/${articleName}`; let articlePath = `${contentDir}/${articleName}`;
let n = 0; if (await api(`/repos/${owner}/${repo}/contents/${encodeURIComponent(articlePath)}?ref=${baseBranch}`)) {
// 注意:Contents API 的 GET 返回 404 表示不存在;gh() 已把 404 转成 null articleName = `${yyyymmdd(date)}-${Math.random().toString(36).slice(2, 8)}.md`;
while (await api(`/repos/${owner}/${repo}/contents/${encodeURIComponent(articlePath)}?ref=${baseBranch}`)) {
n += 1;
articleName = `${yyyymmdd(date)}-${n}.md`;
articlePath = `${contentDir}/${articleName}`; articlePath = `${contentDir}/${articleName}`;
if (n > 50) return json({ error: '同日投稿过多' }, 409);
} }
const markdown = renderMarkdown({ const markdown = renderMarkdown({
@@ -467,7 +491,7 @@ export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
}); });
if (!newCommit) throw new Error('创建 commit 失败'); if (!newCommit) throw new Error('创建 commit 失败');
const branch = `chronicle/${yyyymmdd(date)}-${Math.random().toString(36).slice(2, 7)}`; const branch = `chronicle/${yyyymmdd(date)}-${crypto.randomUUID().slice(0, 8)}`;
await api(`/repos/${owner}/${repo}/git/refs`, { await api(`/repos/${owner}/${repo}/git/refs`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: newCommit.sha }), body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: newCommit.sha }),
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0d1020"/>
<path d="M32 11l6.2 12.6 13.8 2-10 9.7 2.4 13.7L32 42.5l-12.4 6.5 2.4-13.7-10-9.7 13.8-2z" fill="#e6c068"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+29 -3
View File
@@ -1,5 +1,10 @@
/* ================= 星空背景 ================= */ /* ================= 星空背景 ================= */
(function () { (function () {
// 用户偏好减少动态效果时不播星空,保留静态渐变背景
const reducedMotion = window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) return;
const canvas = document.getElementById('stars'); const canvas = document.getElementById('stars');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
let w, h, stars = [], dpr = Math.min(window.devicePixelRatio || 1, 2); let w, h, stars = [], dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -44,11 +49,31 @@
/* ================= 打字机 ================= */ /* ================= 打字机 ================= */
(function () { (function () {
const el = document.getElementById('typed'); const el = document.getElementById('typed');
const lines = [ // 句子由 src/site.config.ts 的 HOME.typedLines 注入(首页内联 JSON),保持单一定制入口
let lines = null;
try {
const src = document.getElementById('typed-lines');
if (src) {
const parsed = JSON.parse(src.textContent);
if (Array.isArray(parsed) && parsed.length && parsed.every(l => typeof l === 'string')) {
lines = parsed;
}
}
} catch (e) { /* 配置缺失时回退到内置句子 */ }
if (!lines) {
lines = [
'我们的故事,从那年秋天开始', '我们的故事,从那年秋天开始',
'三年同窗,一生朋友', '三年同窗,一生朋友',
'愿此去前程似锦,再相逢依然少年' '愿此去前程似锦,再相逢依然少年'
]; ];
}
// 减少动态效果偏好:直接完整展示第一句,不做打字动画
const reducedMotion = window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
el.textContent = lines[0];
return;
}
let li = 0, ci = 0, deleting = false; let li = 0, ci = 0, deleting = false;
function tick() { function tick() {
@@ -262,11 +287,12 @@
} }
return; return;
} catch (err) { } catch (err) {
btn.textContent = err.message && err.message.length <= 10 ? err.message : '发送失败'; // 接口返回的中文提示(如「发得太快啦,请等 X 秒」)直接展示,超长才降级
btn.textContent = err.message && err.message.length <= 30 ? err.message : '发送失败';
setTimeout(() => { setTimeout(() => {
btn.textContent = BTN_TEXT; btn.textContent = BTN_TEXT;
btn.disabled = false; btn.disabled = false;
}, 1600); }, 2400);
sending = false; sending = false;
return; return;
} }
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Disallow: /api/
+5
View File
@@ -37,6 +37,11 @@ const fullTitle = `${date.getFullYear()}·${pad(date.getMonth() + 1)}·${pad(dat
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{fullTitle}</title> <title>{fullTitle}</title>
{summary && <meta name="description" content={summary} />}
<meta property="og:title" content={fullTitle} />
{summary && <meta property="og:description" content={summary} />}
<meta property="og:type" content="article" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/css/pages.css" /> <link rel="stylesheet" href="/css/pages.css" />
</head> </head>
+77
View File
@@ -0,0 +1,77 @@
---
import { SITE } from "../site.config";
// Cloudflare Pages 会自动把构建产物里的 404.html 用作未匹配路由的兜底页
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>页面不存在 · {SITE.className}班史</title>
<meta name="robots" content="noindex" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/css/pages.css" />
<style>
.nf {
text-align: center;
padding: 80px 0 40px;
}
.nf-code {
font-size: 5rem;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--gold, #e6c068);
text-shadow: 0 0 40px rgba(230, 192, 104, 0.35);
}
.nf-text {
margin-top: 10px;
color: var(--muted, #96a0b8);
}
.nf-actions {
margin-top: 30px;
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.nf-actions a {
color: var(--gold, #e6c068);
text-decoration: none;
border: 1px solid rgba(230, 192, 104, 0.35);
padding: 8px 20px;
border-radius: 999px;
transition: background 0.2s ease;
}
.nf-actions a:hover {
background: rgba(230, 192, 104, 0.12);
}
</style>
</head>
<body>
<canvas id="stars"></canvas>
<div class="glow g1"></div>
<div class="glow g2"></div>
<div class="glow g3"></div>
<div class="wrap">
<div class="nf container reveal">
<div class="nf-code">404</div>
<p class="nf-text">这一页还没被写进班史。</p>
<div class="nf-actions">
<a href="/">← 返回首页</a>
<a href="/chronicle">📖 班史列表</a>
<a href="/music">♫ 歌单</a>
</div>
</div>
<footer>
<div class="sig">{SITE.className} · 永远</div>
<p class="small">© {SITE.className}班史 · 制作于某个想起你们的夜晚</p>
</footer>
</div>
<script is:inline src="/js/pages.js"></script>
</body>
</html>
+7
View File
@@ -32,6 +32,13 @@ const items = posts
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班史列表 · {SITE.className}</title> <title>班史列表 · {SITE.className}</title>
<meta
name="description"
content={`${SITE.className}班史全部文章的归档列表,按时间倒序排列。`}
/>
<meta property="og:title" content={`班史列表 · ${SITE.className}`} />
<meta property="og:type" content="website" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/css/pages.css" /> <link rel="stylesheet" href="/css/pages.css" />
<style> <style>
.post-list { .post-list {
+3 -1
View File
@@ -11,7 +11,9 @@ const hasTurnstile = turnstileSiteKey.length > 0;
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班史投稿</title> <title>班史投稿 · {SITE.className}</title>
<meta property="og:title" content={`班史投稿 · ${SITE.className}`} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
{ {
hasTurnstile && ( hasTurnstile && (
<script <script
+27 -90
View File
@@ -1,14 +1,19 @@
--- ---
// 文章列表已移至 /chronicle(班史列表页),首页编年史区块留空 // 文章列表已移至 /chronicle(班史列表页),首页编年史区块留空
import { SITE } from "../site.config"; import { SITE, HOME } from "../site.config";
--- ---
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{SITE.className} · 班史</title> <title>{SITE.className} · 班史</title>
<meta name="description" content={HOME.description} />
<meta property="og:title" content={`${SITE.className} · 班史`} />
<meta property="og:description" content={HOME.description} />
<meta property="og:type" content="website" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/css/index.css" /> <link rel="stylesheet" href="/css/index.css" />
</head> </head>
@@ -53,13 +58,7 @@ import { SITE } from "../site.config";
<h2 class="sec-title"> <h2 class="sec-title">
一间教室,<em>三年光阴</em> 一间教室,<em>三年光阴</em>
</h2> </h2>
<p> {HOME.intro.map((para) => <p>{para}</p>)}
那年秋天,几十个互不相识的名字被写进同一张座位表。风扇转过无数个燥热的午后,黑板擦落下的粉笔灰在阳光里飘成一场小雪。我们从陌生到熟悉,从并肩到并肩。
</p>
<p>
这里有早读时此起彼伏的背书声,有课间十分钟的喧闹与追逐,有考试前互相打气的纸条,也有晚自习窗外那片被灯光照亮的夜色。这些琐碎的日常,后来都成了我们回不去的黄金时代。
</p>
<p>谨以此页,记录 {SITE.className} 走过的每一步。</p>
</div> </div>
<div class="stats reveal"> <div class="stats reveal">
<div class="stat"> <div class="stat">
@@ -88,7 +87,6 @@ import { SITE } from "../site.config";
从初见的那一天起,每一段日子都有了名字。 从初见的那一天起,每一段日子都有了名字。
</p> </p>
</div> </div>
<!-- 文章列表已移至 /chronicle,此处留空 -->
<div class="timeline"></div> <div class="timeline"></div>
</div> </div>
</section> </section>
@@ -102,42 +100,14 @@ import { SITE } from "../site.config";
</div> </div>
<div class="people"> <div class="people">
{HOME.people.map((p) => (
<div class="person reveal"> <div class="person reveal">
<div class="avatar">👑</div> <div class="avatar">{p.avatar}</div>
<h3>班长</h3> <h3>{p.title}</h3>
<div class="role">CAPTAIN</div> <div class="role">{p.role}</div>
<p class="quote">"都安静一下,我说个事儿。"</p> <p class="quote">"{p.quote}"</p>
</div>
<div class="person reveal">
<div class="avatar">📚</div>
<h3>学习委员</h3>
<div class="role">STUDY</div>
<p class="quote">"作业收一下,最后一排。"</p>
</div>
<div class="person reveal">
<div class="avatar">🏃</div>
<h3>体育委员</h3>
<div class="role">SPORTS</div>
<p class="quote">"集合!快点儿,别磨蹭!"</p>
</div>
<div class="person reveal">
<div class="avatar">🎨</div>
<h3>文艺委员</h3>
<div class="role">ART</div>
<p class="quote">"这次板报,我们拿第一。"</p>
</div>
<div class="person reveal">
<div class="avatar">🧹</div>
<h3>劳动委员</h3>
<div class="role">DUTY</div>
<p class="quote">"今天值日的是第三组,别忘了。"</p>
</div>
<div class="person reveal">
<div class="avatar">📣</div>
<h3>宣传委员</h3>
<div class="role">MEDIA</div>
<p class="quote">"照片我都存着呢,一张没删。"</p>
</div> </div>
))}
</div> </div>
</div> </div>
</section> </section>
@@ -150,48 +120,15 @@ import { SITE } from "../site.config";
<p class="sec-desc">属于我们共同的勋章。</p> <p class="sec-desc">属于我们共同的勋章。</p>
</div> </div>
<div class="honors"> <div class="honors">
{HOME.honors.map((h) => (
<div class="honor reveal"> <div class="honor reveal">
<div class="medal">🏆</div> <div class="medal">{h.medal}</div>
<div> <div>
<h4>校运动会团体总分第一</h4> <h4>{h.title}</h4>
<p>20XX · 秋季运动会</p> <p>{h.detail}</p>
</div>
</div>
<div class="honor reveal">
<div class="medal">🥇</div>
<div>
<h4>优秀班集体</h4>
<p>20XX—20XX 学年</p>
</div>
</div>
<div class="honor reveal">
<div class="medal">🎭</div>
<div>
<h4>校园艺术节最佳创意奖</h4>
<p>20XX · 元旦汇演</p>
</div>
</div>
<div class="honor reveal">
<div class="medal">📖</div>
<div>
<h4>经典诵读比赛一等奖</h4>
<p>20XX · 读书节</p>
</div>
</div>
<div class="honor reveal">
<div class="medal">🏀</div>
<div>
<h4>年级篮球赛亚军</h4>
<p>20XX · 春季</p>
</div>
</div>
<div class="honor reveal">
<div class="medal">⭐</div>
<div>
<h4>文明班级 · 三连冠</h4>
<p>连续三个学期</p>
</div> </div>
</div> </div>
))}
</div> </div>
</div> </div>
</section> </section>
@@ -207,14 +144,9 @@ import { SITE } from "../site.config";
</p> </p>
</div> </div>
<div class="gallery"> <div class="gallery">
<div class="photo reveal"><span>开学第一天</span></div> {HOME.gallery.map((label) => (
<div class="photo reveal"><span>运动会合影</span></div> <div class="photo reveal"><span>{label}</span></div>
<div class="photo reveal"><span>元旦晚会</span></div> ))}
<div class="photo reveal"><span>研学路上</span></div>
<div class="photo reveal"><span>教室里</span></div>
<div class="photo reveal"><span>黑板的字</span></div>
<div class="photo reveal"><span>操场黄昏</span></div>
<div class="photo reveal"><span>毕业照</span></div>
</div> </div>
</div> </div>
</section> </section>
@@ -251,6 +183,11 @@ import { SITE } from "../site.config";
</div> </div>
<button id="top" aria-label="回到顶部">↑</button> <button id="top" aria-label="回到顶部">↑</button>
<!-- <Footer /> --> <!-- <Footer /> -->
<script
type="application/json"
id="typed-lines"
set:html={JSON.stringify(HOME.typedLines).replace(/</g, "\\u003c")}
/>
<script src="/js/index.js" is:inline></script> <script src="/js/index.js" is:inline></script>
</body> </body>
</html> </html>
+2
View File
@@ -12,6 +12,8 @@ const hasTurnstile = turnstileSiteKey.length > 0;
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>音乐投稿 · {SITE.className}</title> <title>音乐投稿 · {SITE.className}</title>
<meta property="og:title" content={`音乐投稿 · ${SITE.className}`} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
{ {
hasTurnstile && ( hasTurnstile && (
<script <script
+7
View File
@@ -40,6 +40,13 @@ const songsJson = JSON.stringify(songs).replace(/</g, "\\u003c");
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班级歌单 · {SITE.className}</title> <title>班级歌单 · {SITE.className}</title>
<meta
name="description"
content={`${SITE.className}的班级歌单:同学们推荐的歌曲与它们背后的故事。`}
/>
<meta property="og:title" content={`班级歌单 · ${SITE.className}`} />
<meta property="og:type" content="website" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/css/pages.css" /> <link rel="stylesheet" href="/css/pages.css" />
<style> <style>
.song-list { .song-list {
+48
View File
@@ -20,3 +20,51 @@ export const SITE = {
memories: 999, memories: 999,
}, },
} as const; } as const;
/** 首页各区块的展示文案:换成你们班自己的内容即可,页面会自动引用。 */
export const HOME = {
/** 首页 SEO 用的一句话描述 */
description:
"XX届X班的线上班史:编年史、班级歌单与留言墙,记录我们一起走过的日子。",
/** 首屏打字机轮播的句子 */
typedLines: [
"我们的故事,从那年秋天开始",
"三年同窗,一生朋友",
"愿此去前程似锦,再相逢依然少年",
],
/** 「班级简介」正文段落 */
intro: [
"那年秋天,几十个互不相识的名字被写进同一张座位表。风扇转过无数个燥热的午后,黑板擦落下的粉笔灰在阳光里飘成一场小雪。我们从陌生到熟悉,从并肩到并肩。",
"这里有早读时此起彼伏的背书声,有课间十分钟的喧闹与追逐,有考试前互相打气的纸条,也有晚自习窗外那片被灯光照亮的夜色。这些琐碎的日常,后来都成了我们回不去的黄金时代。",
"谨以此页,记录我们班走过的每一步。",
],
/** 「班级人物志」卡片 */
people: [
{ avatar: "👑", title: "班长", role: "CAPTAIN", quote: "都安静一下,我说个事儿。" },
{ avatar: "📚", title: "学习委员", role: "STUDY", quote: "作业收一下,最后一排。" },
{ avatar: "🏃", title: "体育委员", role: "SPORTS", quote: "集合!快点儿,别磨蹭!" },
{ avatar: "🎨", title: "文艺委员", role: "ART", quote: "这次板报,我们拿第一。" },
{ avatar: "🧹", title: "劳动委员", role: "DUTY", quote: "今天值日的是第三组,别忘了。" },
{ avatar: "📣", title: "宣传委员", role: "MEDIA", quote: "照片我都存着呢,一张没删。" },
],
/** 「班级荣誉」条目 */
honors: [
{ medal: "🏆", title: "校运动会团体总分第一", detail: "20XX · 秋季运动会" },
{ medal: "🥇", title: "优秀班集体", detail: "20XX—20XX 学年" },
{ medal: "🎭", title: "校园艺术节最佳创意奖", detail: "20XX · 元旦汇演" },
{ medal: "📖", title: "经典诵读比赛一等奖", detail: "20XX · 读书节" },
{ medal: "🏀", title: "年级篮球赛亚军", detail: "20XX · 春季" },
{ medal: "⭐", title: "文明班级 · 三连冠", detail: "连续三个学期" },
],
/** 「班级记忆」照片占位标签 */
gallery: [
"开学第一天",
"运动会合影",
"元旦晚会",
"研学路上",
"教室里",
"黑板的字",
"操场黄昏",
"毕业照",
],
} as const;