mirror of
https://github.com/Vb-Lg/class-history-template.git
synced 2026-11-03 17:36:47 +00:00
feat: sync security & SEO hardening from 2910 (keep placeholders)
This commit is contained in:
1 parent
2c1f508b14
commit
339e5b5639
15 files changed
+253
-128
No files matched your search
@@ -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/*`
|
||||||
@@ -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` 中定义。
|
|
||||||
|
|
||||||
## 内容格式
|
## 内容格式
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 }),
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /api/
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in new issue
Block a user