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

+22
View File
@@ -0,0 +1,22 @@
# 复制为 .env 使用。注意:
# - PUBLIC_TURNSTILE_SITE_KEY 是构建时变量,本地构建需要;
# - GITHUB_* / TURNSTILE_SECRET_KEY 是 Cloudflare Pages Functions 的
# 运行时变量,本地 .env 不会生效,需在 Pages 控制台配置(见 README)。
# ---- 投稿后端:GitHub App(详见 README「创建 GitHub App」一节) ----
GITHUB_APP_ID=
GITHUB_INSTALLATION_ID=
GITHUB_PRIVATE_KEY=
GITHUB_OWNER=
GITHUB_REPO=
# ---- 可选覆盖项(不填使用 functions/api/submit.ts 里的默认值) ----
# GITHUB_BASE_BRANCH=main
# CONTENT_DIR=src/content
# MEDIA_DIR=public/pages/static
# MEDIA_URL_PREFIX=/pages/static
# SONGS_DIR=src/songs
# ---- 可选:Cloudflare Turnstile 人机验证(两个都配置才启用) ----
# TURNSTILE_SECRET_KEY=
# PUBLIC_TURNSTILE_SITE_KEY=
+9
View File
@@ -0,0 +1,9 @@
version: 1
labels:
# 班史投稿:chronicle/* 分支(班史投稿表单创建)
- label: "班史投稿"
branch: "^chronicle/"
# 音乐投稿:music/* 分支(音乐投稿表单创建)
- label: "音乐投稿"
branch: "^music/"
+16
View File
@@ -0,0 +1,16 @@
name: Label PR
on:
pull_request:
types: [opened, reopened]
jobs:
label:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: srvaroa/labeler@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+24
View File
@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}
+148
View File
@@ -0,0 +1,148 @@
# 班史模板 class-history-template
一个可直接部署的班级纪念册模板,基于 **Astro 7 + Cloudflare Pages**。
功能一览:
- 🏠 **首页**:班级简介、人物志、荣誉墙、记忆墙(均为示例文案,可自行替换)
- 📖 **班史列表 / 文章页**:`src/content/*.md` 自动生成,按日期升序排列,支持封面、摘要、上下篇导航
- 🎵 **班级歌单**:`src/songs/*.json` 自动生成,支持每日随机排序、随机抽歌
- 💬 **留言墙**:Cloudflare D1 数据库存储
- 📝 **在线投稿**:班史 / 音乐两种表单,提交后自动建分支并发 PR,管理员审核合并后上线(由 GitHub App 驱动)
## 目录结构
```
├── functions/api/ # Cloudflare Pages Functions
│ ├── submit.ts # 投稿 → GitHub PR
│ └── messages.js # 留言墙(需绑定 D1)
├── public/
│ ├── css/ js/ # 样式与脚本
│ └── pages/static/ # 文章引用的静态资源(封面图、照片等)
├── src/
│ ├── content/ # 班史文章(YYYYMMDD.md)
│ ├── songs/ # 歌单(*.json)
│ ├── pages/ # 页面
│ ├── layouts/ # 文章页布局
│ ├── plugins/ # markdown 入场动画插件
│ └── site.config.ts # ★ 班级信息配置
└── .env.example # 环境变量示例
```
## 快速开始
需要 Node.js ≥ 22.12。
```bash
npm install
npm run dev # http://localhost:4321
```
注意:`npm run dev` 只渲染页面,`/api/*` 由 Pages Functions 提供服务,只有线上可用——本地留言墙会显示「加载失败」、投稿提交会报错,属正常现象。需要本地调试 API 时可用 `npx wrangler pages dev`。
## 定制班级信息
1. **`src/site.config.ts`**:班级名、顶栏 logo、首屏徽章、统计数字,改这一个文件即可。
2. **`src/pages/index.astro`**:班级简介、人物志、荣誉、记忆墙的示例文案(搜索 `20XX` 和各区块的中文示例)。
3. **`public/js/index.js`**:首屏打字机轮播文案(`lines` 数组)。
4. 主题色(金色 `#e6c068`)在 `public/css/*.css` 中定义。
## 内容格式
### 班史文章 `src/content/YYYYMMDD.md`
文件名用日期(同日多篇加 `-1`、`-2` 后缀),frontmatter 字段:
| 字段 | 必填 | 说明 |
| -------- | ---- | --------------------------------------- |
| `title` | ✓ | 标题 |
| `date` | ✓ | 日期 `YYYY-MM-DD`,决定排序 |
| `summary`| | 摘要,显示在列表页和文章页副标题 |
| `author` | | 记录人 |
| `cover` | | 封面图路径,如 `/pages/static/xxx.png` |
正文支持完整 Markdown,含 `<details>` 折叠块。
### 歌曲 `src/songs/*.json`
```json
{
"title": "歌名",
"artist": "歌手",
"date": "20250901",
"link": "https://...",
"note": "这首歌和班级的故事",
"author": "推荐人"
}
```
`title`、`artist` 必填;`link` 须以 `http(s)://` 开头。
> 模板自带一篇示例文章(`src/content/20250101.md`)、一首示例歌曲(`src/songs/20250101-example.json`)和一张占位封面(`public/pages/static/cover-placeholder.svg`),正式使用前请删除前两者。
## 部署(Cloudflare Pages)
1. 推送到 GitHub 仓库。
2. Cloudflare Dashboard → Workers & Pages → Create → Pages → 连接该仓库:
- Build command:`npm run build`
- Build output directory:`dist`
- 环境变量加 `NODE_VERSION = 22.12.0`
3. `functions/` 目录会被自动部署为 Pages Functions。
### 创建 GitHub App(投稿功能需要)
1. GitHub → Settings → Developer settings → **GitHub Apps** → New GitHub App。
- Homepage URL 随意填(如仓库地址);不需要回调与 webhook。
- Repository permissions:**Contents → Read and write**,**Pull requests → Read and write**。
2. 创建后点 **Generate a private key**,下载 `.pem` 文件。
3. 左侧 **Install App**,安装到你的仓库;安装页 URL 中的数字即 **Installation ID**(如 `https://github.com/settings/installations/12345` 中的 `12345`)。
4. 在 Pages 项目 → Settings → Variables and Secrets 中配置环境变量(Production 与 Preview 都要):
| 变量 | 必填 | 说明 |
| ----------------------- | ---- | ------------------------------------------------- |
| `GITHUB_APP_ID` | ✓ | GitHub App 的 App ID |
| `GITHUB_INSTALLATION_ID`| ✓ | 安装后的 Installation ID |
| `GITHUB_PRIVATE_KEY` | ✓ | `.pem` 私钥全文(含 BEGIN/END 行) |
| `GITHUB_OWNER` | ✓ | 仓库所有者(用户名或组织名) |
| `GITHUB_REPO` | ✓ | 仓库名 |
| `GITHUB_BASE_BRANCH` | | 投稿目标分支,默认 `main` |
| `CONTENT_DIR` 等 | | 投稿落盘目录,默认值见 `functions/api/submit.ts` |
### 留言墙(D1 数据库)
1. Dashboard → Storage & Databases → **D1** → 创建数据库。
2. 在控制台执行建表:
```sql
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
text TEXT NOT NULL,
time TEXT NOT NULL,
ip TEXT,
created_at INTEGER NOT NULL
);
```
3. Pages 项目 → Settings → Functions → **D1 database bindings**,绑定名必须为 **`DB`**。
### 人机验证(可选,Cloudflare Turnstile)
Dashboard → Turnstile → 添加站点,拿到一对 key:
- `PUBLIC_TURNSTILE_SITE_KEY`:**构建时**变量(也写入本地 `.env` 供本地构建);
- `TURNSTILE_SECRET_KEY`:运行时变量。
两个都配置后投稿表单才启用人机验证;不配置则跳过。
## 投稿流程
表单提交 → `/api/submit` 用 GitHub App 建分支(`chronicle/*` 或 `music/*`)并开 PR → 管理员在 GitHub 审核合并 → Pages 自动重新构建,内容上线。`.github/labeler.yml` 会给投稿 PR 自动打标签。
## 常用命令
```bash
npm run dev # 本地开发
npm run build # 构建到 dist/
npm run preview # 预览构建产物
```
+14
View File
@@ -0,0 +1,14 @@
// @ts-check
import { defineConfig } from 'astro/config';
import { satteri } from '@astrojs/markdown-satteri';
import markdownReveal from './src/plugins/markdown-reveal.mjs';
// https://astro.build/config
export default defineConfig({
markdown: {
// Astro 7 的默认 markdown 处理器是 Sätteri(@astrojs/markdown-remark
// 已不再默认安装)。这里在它的 hast 阶段挂上插件,让 markdown 转
// HTML 时自动给正文段落补 .reveal,源文件不用手写 class。
processor: satteri({ features: { rawHtml: true }, hastPlugins: [markdownReveal] }),
},
});
+169
View File
@@ -0,0 +1,169 @@
/**
* Cloudflare Pages Functions
* GET /api/messages → 返回最新留言列表
* POST /api/messages → 新增一条留言
* 依赖绑定:环境变量 DB(D1 数据库)
*/
const MAX_NAME = 12;
const MAX_TEXT = 120;
const MAX_LIST = 200; // 列表最多返回多少条
const COOLDOWN = 20; // 同一 IP 两条留言的最小间隔(秒)
/* ---------- 工具 ---------- */
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
// 如果前端和接口不同域,可加上 CORS 头;同域可删掉
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
}
});
}
function today() {
const d = new Date();
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())}`;
}
/* 清理文本:去掉控制字符、折叠多余空白 */
function clean(str, max) {
return String(str ?? '')
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, max);
}
/* ---------- 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:拉取列表 ---------- */
export async function onRequestGet({ env }) {
if (!env.DB) return json({ ok: false, error: '数据库未绑定' }, 500);
try {
const { results } = await env.DB
.prepare(
`SELECT id, name, text, time
FROM messages
ORDER BY created_at DESC, id DESC
LIMIT ?`
)
.bind(MAX_LIST)
.all();
return json({ ok: true, data: results ?? [] });
} catch (err) {
console.error('[GET /api/messages]', err);
return json({ ok: false, error: '读取失败,请稍后再试' }, 500);
}
}
/* ---------- POST:新增留言 ---------- */
export async function onRequestPost({ request, env }) {
if (!env.DB) return json({ ok: false, error: '数据库未绑定' }, 500);
// 1. 解析请求体
let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: '请求格式错误' }, 400);
}
// 2. 校验内容
const name = clean(body.name, MAX_NAME) || '匿名同学';
const text = clean(body.text, MAX_TEXT);
if (!text) {
return json({ ok: false, error: '内容不能为空' }, 400);
}
if (String(body.text ?? '').length > 500) {
return json({ ok: false, error: '内容过长' }, 400);
}
// 3. 简单限流:同一 IP 冷却时间内只能发一条
const ip =
request.headers.get('CF-Connecting-IP') ||
request.headers.get('X-Forwarded-For') ||
'';
const now = Math.floor(Date.now() / 1000);
try {
if (ip) {
const last = await env.DB
.prepare(
`SELECT created_at FROM messages
WHERE ip = ?
ORDER BY created_at DESC
LIMIT 1`
)
.bind(ip)
.first();
if (last && now - last.created_at < COOLDOWN) {
const wait = COOLDOWN - (now - last.created_at);
return json(
{ ok: false, error: `发得太快啦,请等 ${wait} 秒` },
429
);
}
}
// 4. 写入数据库
const time = today();
const res = await env.DB
.prepare(
`INSERT INTO messages (name, text, time, ip, created_at)
VALUES (?, ?, ?, ?, ?)`
)
.bind(name, text, time, ip, now)
.run();
// 5. 顺手裁剪:只保留最近 500 条,避免无限增长
// 如果不需要可以删掉这一段
try {
await env.DB
.prepare(
`DELETE FROM messages
WHERE id NOT IN (
SELECT id FROM messages
ORDER BY created_at DESC, id DESC
LIMIT 500
)`
)
.run();
} catch (e) {
console.warn('[cleanup]', e);
}
return json({
ok: true,
data: {
id: res.meta?.last_row_id ?? null,
name,
text,
time
}
});
} catch (err) {
console.error('[POST /api/messages]', err);
return json({ ok: false, error: '保存失败,请稍后再试' }, 500);
}
}
+480
View File
@@ -0,0 +1,480 @@
// functions/api/submit.ts
interface Env {
GITHUB_APP_ID: string;
GITHUB_INSTALLATION_ID: string;
GITHUB_PRIVATE_KEY: string;
GITHUB_OWNER: string;
GITHUB_REPO: string;
GITHUB_BASE_BRANCH?: string; // 默认 main
CONTENT_DIR?: string; // 默认 src/content(对齐 Pages CMS 的 path)
MEDIA_DIR?: string; // 默认 public/pages/static
MEDIA_URL_PREFIX?: string; // 默认 /pages/static
SONGS_DIR?: string; // 默认 src/songs(音乐投稿的目标目录)
TURNSTILE_SECRET_KEY?: string; // Turnstile 私钥;未设置则跳过校验(本地开发)
}
const enc = new TextEncoder();
// ---------- 编码/加密工具 ----------
function b64url(bytes: Uint8Array | ArrayBuffer): string {
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let bin = '';
const chunk = 0x8000;
for (let i = 0; i < arr.length; i += chunk) {
bin += String.fromCharCode(...arr.subarray(i, i + chunk));
}
return btoa(bin).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
}
const b64utf8 = (s: string) => b64url(enc.encode(s));
function abToBase64(buf: ArrayBuffer): string {
const arr = new Uint8Array(buf);
let bin = '';
const chunk = 0x8000;
for (let i = 0; i < arr.length; i += chunk) bin += String.fromCharCode(...arr.subarray(i, i + chunk));
return btoa(bin);
}
function pemToArrayBuffer(pem: string): ArrayBuffer {
const fixed = pem.includes('\n')
? pem
: pem.replace(/-----BEGIN [^-]+-----\s*/, '-----BEGIN PRIVATE KEY-----\n')
.replace(/\s*-----END PRIVATE KEY-----/, '\n-----END PRIVATE KEY-----');
const b64 = fixed
.replace(/-----BEGIN [^-]+-----/, '')
.replace(/-----END [^-]+-----/, '')
.replace(/\s+/g, '');
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
async function makeAppJwt(appId: string, pem: string) {
const now = Math.floor(Date.now() / 1000);
const head = b64utf8(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const body = b64utf8(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId }));
const data = `${head}.${body}`;
const key = await crypto.subtle.importKey(
'pkcs8', pemToArrayBuffer(pem),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'],
);
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, enc.encode(data));
return `${data}.${b64url(sig)}`;
}
async function getInstallationToken(env: Env): Promise<string> {
const jwt = await makeAppJwt(env.GITHUB_APP_ID, env.GITHUB_PRIVATE_KEY);
const res = await fetch(
`https://api.github.com/app/installations/${env.GITHUB_INSTALLATION_ID}/access_tokens`,
{
method: 'POST',
headers: {
authorization: `Bearer ${jwt}`,
accept: 'application/vnd.github+json',
'user-agent': 'astro-chronicle-fn',
},
},
);
if (!res.ok) throw new Error(`installation token failed: ${res.status} ${await res.text()}`);
return (await res.json()).token as string;
}
// ---------- Turnstile ----------
async function verifyTurnstile(
secret: string,
token: string,
remoteIp?: string,
): Promise<boolean> {
const body = new URLSearchParams({ secret, response: token });
if (remoteIp) body.set('remoteip', remoteIp);
const res = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
},
);
if (!res.ok) return false;
const data = (await res.json()) as { success?: boolean };
return data.success === true;
}
// ---------- GitHub REST ----------
function gh(token: string) {
return async function api<T = any>(path: string, init: RequestInit = {}): Promise<T | null> {
const res = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
authorization: `Bearer ${token}`,
accept: 'application/vnd.github+json',
'user-agent': 'astro-chronicle-fn',
'content-type': 'application/json',
...(init.headers || {}),
},
});
if (res.status === 404) return null; // 用于探测文件是否存在
if (!res.ok) throw new Error(`GitHub ${path} → ${res.status}: ${await res.text()}`);
return res.json() as Promise<T>;
};
}
// ---------- 业务辅助 ----------
const yyyymmdd = (iso: string) => iso.replace(/-/g, '');
function extFromMime(mime: string): string {
const m = mime.toLowerCase();
if (m.includes('png')) return 'png';
if (m.includes('jpeg') || m.includes('jpg')) return 'jpg';
if (m.includes('webp')) return 'webp';
if (m.includes('gif')) return 'gif';
if (m.includes('avif')) return 'avif';
if (m.includes('svg')) return 'svg';
return 'bin';
}
function yamlQuote(s: string): string {
// 始终用 JSON 字符串(YAML 兼容)
return JSON.stringify(s);
}
// 生成 frontmatter + 正文(严格对齐 Pages CMS 字段)
function renderMarkdown(f: {
title: string; date: string; summary?: string;
author?: string; cover?: string; body: string;
}) {
const fm = ['---', `title: ${yamlQuote(f.title)}`, `date: ${yamlQuote(f.date)}`];
if (f.summary) fm.push(`summary: ${yamlQuote(f.summary)}`);
if (f.author) fm.push(`author: ${yamlQuote(f.author)}`);
if (f.cover) fm.push(`cover: ${yamlQuote(f.cover)}`);
fm.push('---');
return `${fm.join('\n')}\n\n${f.body.trim()}\n`;
}
// ---------- 音乐投稿 ----------
function renderSongJson(f: {
title: string; artist: string; link?: string;
note?: string; author?: string; date: string;
}) {
const obj: Record<string, string> = {
title: f.title,
artist: f.artist,
date: f.date,
};
if (f.link) obj.link = f.link;
if (f.note) obj.note = f.note;
if (f.author) obj.author = f.author;
return JSON.stringify(obj, null, 2) + '\n';
}
async function handleSongSubmit(
env: Env, fd: FormData, json: (data: unknown, status?: number) => Response,
): Promise<Response> {
const title = (fd.get('title') as string | null)?.trim() ?? '';
const artist = (fd.get('artist') as string | null)?.trim() ?? '';
const link = (fd.get('link') as string | null)?.trim() ?? '';
const note = (fd.get('note') as string | null)?.trim() ?? '';
const author = (fd.get('author') as string | null)?.trim() ?? '';
const message = (fd.get('message') as string | null)?.trim() ?? '';
if (!title) return json({ error: '歌名不能为空' }, 400);
if (!artist) return json({ error: '歌手不能为空' }, 400);
if (title.length > 200) return json({ error: '歌名过长' }, 400);
if (artist.length > 120) return json({ error: '歌手名过长' }, 400);
if (note.length > 500) return json({ error: '备注过长' }, 400);
if (link && !/^https?:\/\//i.test(link)) {
return json({ error: '链接必须以 http:// 或 https:// 开头' }, 400);
}
const baseBranch = env.GITHUB_BASE_BRANCH ?? 'main';
const songsDir = env.SONGS_DIR ?? 'src/songs';
const owner = env.GITHUB_OWNER;
const repo = env.GITHUB_REPO;
const token = await getInstallationToken(env);
const api = gh(token);
// 歌曲文件名:YYYYMMDD-<随机>,避免冲突
const today = new Date();
const pad2 = (n: number) => String(n).padStart(2, '0');
const ymd = `${today.getFullYear()}${pad2(today.getMonth() + 1)}${pad2(today.getDate())}`;
const songPath = `${songsDir}/${ymd}-${Math.random().toString(36).slice(2, 8)}.json`;
const content = renderSongJson({ title, artist, link, note, author, date: ymd });
const baseRef = await api<{ object: { sha: string } }>(
`/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
);
if (!baseRef) throw new Error(`分支不存在: ${baseBranch}`);
const baseCommitSha = baseRef.object.sha;
const baseCommit = await api<{ tree: { sha: string } }>(
`/repos/${owner}/${repo}/git/commits/${baseCommitSha}`,
);
if (!baseCommit) throw new Error('无法获取 base commit');
const blob = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/blobs`, {
method: 'POST',
body: JSON.stringify({ content, encoding: 'utf-8' }),
});
if (!blob) throw new Error('创建歌曲 blob 失败');
const newTree = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/trees`, {
method: 'POST',
body: JSON.stringify({
base_tree: baseCommit.tree.sha,
tree: [{ path: songPath, mode: '100644', type: 'blob', sha: blob.sha }],
}),
});
if (!newTree) throw new Error('创建 tree 失败');
const commitMsg = `feat(music): add "${title}" - ${artist}` +
(author ? ` by ${author}` : '');
const newCommit = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/commits`, {
method: 'POST',
body: JSON.stringify({
message: commitMsg,
tree: newTree.sha,
parents: [baseCommitSha],
}),
});
if (!newCommit) throw new Error('创建 commit 失败');
const branch = `music/${ymd}-${Math.random().toString(36).slice(2, 7)}`;
await api(`/repos/${owner}/${repo}/git/refs`, {
method: 'POST',
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: newCommit.sha }),
});
const prBody = [
'**投稿类型:** 🎵 音乐',
`**歌名:** ${title}`,
`**歌手:** ${artist}`,
author ? `**推荐人:** ${author}` : '',
link ? `**链接:** ${link}` : '',
note ? `**备注:** ${note}` : '',
'',
`**目标文件:** \`${songPath}\``,
'',
message ? `**留言:**\n\n${message}` : '',
'',
'---',
'由音乐投稿表单自动创建,请审核后合并。',
].filter(Boolean).join('\n');
const pr = await api<{ html_url: string; number: number }>(
`/repos/${owner}/${repo}/pulls`,
{
method: 'POST',
body: JSON.stringify({
title: `🎵 音乐投稿:${title} - ${artist}`,
head: branch,
base: baseBranch,
body: prBody,
}),
},
);
if (!pr) throw new Error('创建 PR 失败');
return json({ prUrl: pr.html_url, number: pr.number, file: songPath });
}
// ---------- 主处理 ----------
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { 'content-type': 'application/json; charset=utf-8' },
});
try {
// 1) 解析 multipart
const ct = request.headers.get('content-type') || '';
if (!ct.includes('multipart/form-data')) {
return json({ error: '需要 multipart/form-data' }, 400);
}
const fd = await request.formData();
// 1.5) Turnstile 人机验证(配置了 secret 才启用)
const turnstileSecret = env.TURNSTILE_SECRET_KEY?.trim();
if (turnstileSecret) {
const raw = fd.get('cf-turnstile-response');
const token = typeof raw === 'string' ? raw.trim() : '';
if (!token) {
return json({ error: '请先完成人机验证' }, 400);
}
const ip = request.headers.get('CF-Connecting-IP') ?? undefined;
const ok = await verifyTurnstile(turnstileSecret, token, ip);
if (!ok) {
return json({ error: '人机验证未通过,请刷新页面后重试' }, 403);
}
}
// 1.7) 音乐投稿分支(type=music)
const submitType = (fd.get('type') as string | null)?.trim() ?? 'chronicle';
if (submitType === 'music') {
return await handleSongSubmit(env, fd, json);
}
const title = (fd.get('title') as string | null)?.trim() ?? '';
const date = (fd.get('date') as string | null)?.trim() ?? '';
const summary = (fd.get('summary') as string | null)?.trim() ?? '';
const author = (fd.get('author') as string | null)?.trim() ?? '';
const message = (fd.get('message') as string | null)?.trim() ?? '';
const body = (fd.get('body') as string | null)?.trim() ?? '';
const cover = fd.get('cover');
if (!title) return json({ error: '标题不能为空' }, 400);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return json({ error: '日期格式应为 YYYY-MM-DD' }, 400);
if (!body) return json({ error: '正文不能为空' }, 400);
if (title.length > 200) return json({ error: '标题过长' }, 400);
if (body.length > 200_000) return json({ error: '正文过长' }, 400);
const baseBranch = env.GITHUB_BASE_BRANCH ?? 'main';
const contentDir = env.CONTENT_DIR ?? 'src/content';
const mediaDir = env.MEDIA_DIR ?? 'public/pages/static';
const mediaUrl = env.MEDIA_URL_PREFIX ?? '/pages/static';
const owner = env.GITHUB_OWNER;
const repo = env.GITHUB_REPO;
const token = await getInstallationToken(env);
const api = gh(token);
// 2) 处理封面图(若有)
let coverPath: string | undefined; // 仓库内路径
let coverUrl: string | undefined; // markdown 引用
let coverBlob: { content: string; encoding: 'base64' } | undefined;
if (cover instanceof File && cover.size > 0) {
if (cover.size > 5 * 1024 * 1024) return json({ error: '封面图超过 5 MB' }, 413);
if (!cover.type.startsWith('image/')) return json({ error: '封面必须是图片' }, 400);
const ext = extFromMime(cover.type);
const base = `${yyyymmdd(date)}-cover`;
coverPath = `${mediaDir}/${base}.${ext}`;
coverUrl = `${mediaUrl}/${base}.${ext}`;
const buf = await cover.arrayBuffer();
coverBlob = { content: abToBase64(buf), encoding: 'base64' };
}
// 3) 决定文章文件名(同日冲突 → -1, -2, …)
let articleName = `${yyyymmdd(date)}.md`;
let articlePath = `${contentDir}/${articleName}`;
let n = 0;
// 注意:Contents API 的 GET 返回 404 表示不存在;gh() 已把 404 转成 null
while (await api(`/repos/${owner}/${repo}/contents/${encodeURIComponent(articlePath)}?ref=${baseBranch}`)) {
n += 1;
articleName = `${yyyymmdd(date)}-${n}.md`;
articlePath = `${contentDir}/${articleName}`;
if (n > 50) return json({ error: '同日投稿过多' }, 409);
}
const markdown = renderMarkdown({
title, date, summary, author, cover: coverUrl, body,
});
// 4) 从 base 分支拿 commit & tree
const baseRef = await api<{ object: { sha: string } }>(
`/repos/${owner}/${repo}/git/ref/heads/${baseBranch}`,
);
if (!baseRef) throw new Error(`分支不存在: ${baseBranch}`);
const baseCommitSha = baseRef.object.sha;
const baseCommit = await api<{ tree: { sha: string } }>(
`/repos/${owner}/${repo}/git/commits/${baseCommitSha}`,
);
if (!baseCommit) throw new Error('无法获取 base commit');
const baseTreeSha = baseCommit.tree.sha;
// 5) 上传 blobs
const treeEntries: Array<{
path: string; mode: '100644'; type: 'blob'; sha: string;
}> = [];
// markdown blob(utf-8)
const mdBlob = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/blobs`, {
method: 'POST',
body: JSON.stringify({ content: markdown, encoding: 'utf-8' }),
});
if (!mdBlob) throw new Error('创建 markdown blob 失败');
treeEntries.push({ path: articlePath, mode: '100644', type: 'blob', sha: mdBlob.sha });
// 封面 blob(base64)
if (coverBlob && coverPath) {
const imgBlob = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/blobs`, {
method: 'POST',
body: JSON.stringify(coverBlob),
});
if (!imgBlob) throw new Error('创建封面 blob 失败');
treeEntries.push({ path: coverPath, mode: '100644', type: 'blob', sha: imgBlob.sha });
}
// 6) 建 tree / commit / 分支
const newTree = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/trees`, {
method: 'POST',
body: JSON.stringify({ base_tree: baseTreeSha, tree: treeEntries }),
});
if (!newTree) throw new Error('创建 tree 失败');
const commitMsg = `feat(chronicle): add "${title}" (${date})` +
(author ? ` by ${author}` : '');
const newCommit = await api<{ sha: string }>(`/repos/${owner}/${repo}/git/commits`, {
method: 'POST',
body: JSON.stringify({
message: commitMsg,
tree: newTree.sha,
parents: [baseCommitSha],
}),
});
if (!newCommit) throw new Error('创建 commit 失败');
const branch = `chronicle/${yyyymmdd(date)}-${Math.random().toString(36).slice(2, 7)}`;
await api(`/repos/${owner}/${repo}/git/refs`, {
method: 'POST',
body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: newCommit.sha }),
});
// 7) 创建 PR
const prBody = [
`**标题:** ${title}`,
`**日期:** ${date}`,
author ? `**记录人:** ${author}` : '',
summary ? `**摘要:** ${summary}` : '',
coverUrl ? `**封面:** \`${coverUrl}\`` : '',
'',
`**目标文件:** \`${articlePath}\``,
'',
message ? `**留言:**\n\n${message}` : '',
'',
'---',
'由 Astro 投稿表单自动创建,请审核后合并。',
].filter(Boolean).join('\n');
const pr = await api<{ html_url: string; number: number }>(
`/repos/${owner}/${repo}/pulls`,
{
method: 'POST',
body: JSON.stringify({
title: `📝 班史投稿:${title}`,
head: branch,
base: baseBranch,
body: prBody,
}),
},
);
if (!pr) throw new Error('创建 PR 失败');
return json({
prUrl: pr.html_url,
number: pr.number,
file: articlePath,
cover: coverUrl ?? null,
});
} catch (err: any) {
console.error(err);
return json({ error: err?.message ?? '服务异常' }, 500);
}
};
+4229
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "class-history-template",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/markdown-satteri": "0.4.2",
"astro": "^7.3.5"
},
"allowScripts": {
"esbuild": true
}
}
+1126
View File
File diff suppressed because it is too large. Load diff
+600
View File
@@ -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);
}
+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' }));
})();
+3
View File
@@ -0,0 +1,3 @@
文章引用的静态资源(封面图、照片、音视频等)放在这个目录,正文里以 `/pages/static/文件名` 引用。
投稿表单上传的封面图也会由后端提交到这个目录(路径见 `functions/api/submit.ts` 的 `MEDIA_DIR`)。
+11
View File
@@ -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

+28
View File
@@ -0,0 +1,28 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const chronicle = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
summary: z.string().optional(),
author: z.string().optional(),
cover: z.string().optional(),
}),
});
// 班级歌单:投稿表单生成的 JSON(src/songs/*.json)
const songs = defineCollection({
loader: glob({ pattern: '**/*.json', base: './src/songs' }),
schema: z.object({
title: z.string(),
artist: z.string(),
link: z.string().optional(),
note: z.string().optional(),
author: z.string().optional(),
date: z.string().optional(),
}),
});
export const collections = { chronicle, songs };
+33
View File
@@ -0,0 +1,33 @@
---
title: 示例文章:从这里开始你们的班史
date: 2025-01-01
summary: 展示班史文章的写法与排版效果,正式使用前请删除本文
author: 站长
cover: /pages/static/cover-placeholder.svg
---
# 欢迎使用班史模板
把 `src/content/` 下的 markdown 文件删删改改,这个班级的故事就开始了。
## 文件名与排序
- 文章文件名使用 `YYYYMMDD.md`(如 `20250901.md`),同一天多篇时加 `-1`、`-2` 后缀
- 班史列表和上一篇/下一篇按 `date` 字段**升序**排列
## 可以使用的排版
正文支持完整 Markdown:**加粗**、*斜体*、[链接](/chronicle)、列表、表格、引用……
> 黑板上的倒计时、课桌上的划痕、放学路上的夕阳——都值得写进班史。
<details>
<summary>折叠内容(点击展开)</summary>
适合放长文、聊天记录或不剧透的彩蛋。
</details>
![封面占位图](/pages/static/cover-placeholder.svg)
封面在 frontmatter 的 `cover` 字段设置:图片放到 `public/pages/static/` 目录,引用时写 `/pages/static/文件名`。
+193
View File
@@ -0,0 +1,193 @@
---
import { SITE } from "../site.config";
interface Props {
entry: {
id: string;
data: {
title: string;
date: Date;
summary?: string;
author?: string;
cover?: string;
};
};
index: number;
total: number;
prev: { url: string; title: string } | null;
next: { url: string; title: string } | null;
rendered: {
Content: any;
headings: { depth: number; slug: string; text: string }[];
};
}
const { entry, index, total, prev, next, rendered } = Astro.props;
const { Content } = rendered;
const { title, date, summary, author, cover } = entry.data;
const pad = (n: number) => String(n).padStart(2, '0');
const dateLabel = `${date.getFullYear()} · ${pad(date.getMonth() + 1)} · ${pad(date.getDate())}`;
const fullTitle = `${date.getFullYear()}·${pad(date.getMonth() + 1)}·${pad(date.getDate())} · ${SITE.className}班史`;
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{fullTitle}</title>
<link rel="stylesheet" href="/css/pages.css" />
</head>
<body>
<canvas id="stars"></canvas>
<div class="glow g1"></div>
<div class="glow g2"></div>
<div class="glow g3"></div>
<div id="progress"></div>
<!-- 顶部导航 -->
<div class="topbar">
<a class="back" href="/">← 返回班史</a>
<span class="nav-tag">{SITE.className} · 班史</span>
<div class="topbar-actions">
<a class="back" href="/music">♫ 歌单</a>
<a class="back" href="/contribute">📝 投稿</a>
</div>
</div>
<div class="wrap">
<!-- ========== 文章头部 ========== -->
<header class="post-head container">
<div class="post-date">{dateLabel}</div>
<h1>{title}</h1>
{summary && <p class="post-sub">{summary}</p>}
<div class="post-meta">
{author && <span>记录人 · {author}</span>}<i>·</i>
<span>第 {String(index + 1).padStart(2, '0')} 篇</span>
<i>·</i>
<span>共 {total} 篇</span>
</div>
<div class="head-line"></div>
</header>
<!-- ========== 封面图 ========== -->
{
cover && (
<div class="cover reveal">
<img
src={cover}
class="ph"
alt="封面"
style="object-fit:cover;width:100%;height:100%;"
/>
</div>
)
}
<!-- ========== 正文 ========== -->
<article class="container post-body">
<div class="prose">
<Content />
</div>
</article>
<!-- ========== 上下篇 ========== -->
<div class="container">
<div class="post-nav reveal">
<a href={prev ? prev.url : '/'}>
<div class="label">← PREV</div>
<div class="title">
{prev ? prev.title : '返回班史首页'}
</div>
</a>
{
next ? (
<a class="next" href={next.url}>
<div class="label">NEXT →</div>
<div class="title">{next.title}</div>
</a>
) : (
<span />
)
}
</div>
</div>
<!-- ========== 页脚 ========== -->
<footer>
<div class="sig">{SITE.className} · 永远</div>
<p>愿我们都被世界温柔以待,也永远记得这间教室</p>
<p class="small">© {SITE.className}班史 · 制作于某个想起你们的夜晚</p>
</footer>
</div>
<button id="top" aria-label="回到顶部">↑</button>
<script src="/js/pages.js" is:inline></script>
</body>
</html>
<style>
/* markdown 正文内的元素套用与 .post-body 一致的排版 */
.prose :global(p) {
margin: 0 0 22px;
font-size: 16.5px;
line-height: 1.95;
color: var(--text-dim);
}
.prose :global(strong) {
color: var(--gold-soft);
}
.prose :global(a) {
color: var(--gold-soft);
text-decoration: none;
border-bottom: 1px dashed rgba(212, 175, 55, 0.4);
}
.prose :global(a:hover) {
border-bottom-style: solid;
}
.prose :global(img) {
max-width: 100%;
border-radius: 12px;
margin: 10px 0;
}
.prose :global(ul) {
margin: 0 0 22px;
padding-left: 24px;
}
.prose :global(table) {
width: 100%;
margin: 0 0 22px;
border-collapse: collapse;
color: var(--text-dim);
}
.prose :global(th),
.prose :global(td) {
padding: 10px 14px;
border: 1px solid rgba(230, 192, 104, 0.2);
text-align: left;
}
.prose :global(th) {
color: var(--gold-soft);
background: rgba(230, 192, 104, 0.07);
font-weight: 600;
}
.prose :global(li) {
margin: 6px 0;
color: var(--text-dim);
line-height: 1.8;
}
.prose :global(details) {
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 14px;
padding: 16px 20px;
margin: 0 0 22px;
}
.prose :global(summary) {
cursor: pointer;
color: var(--gold-soft);
font-weight: 600;
}
</style>
+206
View File
@@ -0,0 +1,206 @@
---
export const prerender = true;
import { getCollection } from "astro:content";
import { SITE } from "../site.config";
// 班史列表:src/content/*.md(与首页「班级编年史」同源)
// 升序排列,序号与文章页「第 N 篇」一致
const posts = (await getCollection("chronicle")).sort(
(a, b) => a.data.date.valueOf() - b.data.date.valueOf(),
);
const pad = (n: number) => String(n).padStart(2, "0");
const total = posts.length;
const items = posts.map((post, i) => {
const d = post.data.date;
return {
index: i + 1,
dateLabel: `${d.getFullYear()} · ${pad(d.getMonth() + 1)} · ${pad(d.getDate())}`,
title: post.data.title,
summary: post.data.summary,
author: post.data.author,
url: `/pages/${post.id.split("/").pop()}`,
};
});
---
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班史列表 · {SITE.className}</title>
<link rel="stylesheet" href="/css/pages.css" />
<style>
.post-list {
list-style: none;
padding: 0;
margin: 0;
}
.post-item {
margin-bottom: 14px;
}
.post-link {
display: flex;
align-items: center;
gap: 18px;
padding: 18px 20px;
border: 1px solid rgba(230, 192, 104, 0.16);
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
text-decoration: none;
transition:
transform 0.25s ease,
border-color 0.25s ease,
background 0.25s ease;
}
.post-link:hover {
transform: translateX(6px);
border-color: rgba(230, 192, 104, 0.45);
background: rgba(255, 255, 255, 0.06);
}
.post-link .no {
font-size: 0.85rem;
color: var(--muted, #96a0b8);
font-variant-numeric: tabular-nums;
min-width: 2.2em;
text-align: right;
}
.post-link .info {
flex: 1;
min-width: 0;
}
.post-link .date {
font-size: 0.78rem;
letter-spacing: 0.18em;
color: var(--gold, #e6c068);
margin-bottom: 2px;
}
.post-link .t {
font-size: 1.05rem;
color: var(--text, #e9ecf5);
font-weight: 600;
}
.post-link .s {
font-size: 0.85rem;
color: var(--muted, #96a0b8);
margin-top: 4px;
}
.post-link .by {
font-size: 0.78rem;
color: var(--muted, #96a0b8);
margin-top: 2px;
}
.post-link .by::before {
content: "✍️ ";
opacity: 0.8;
}
.post-link .go {
font-size: 0.8rem;
color: var(--gold, #e6c068);
border: 1px solid rgba(230, 192, 104, 0.35);
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
transition: background 0.2s ease;
}
.post-link:hover .go {
background: rgba(230, 192, 104, 0.15);
}
.post-item.reveal {
opacity: 0;
transform: translateX(24px);
transition:
opacity 0.55s ease,
transform 0.55s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.post-item.reveal.visible {
opacity: 1;
transform: translateX(0);
}
@media (prefers-reduced-motion: reduce) {
.post-item.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
@media (max-width: 600px) {
.post-link .go {
display: none;
}
}
.empty {
color: var(--muted, #96a0b8);
text-align: center;
padding: 40px 0;
}
</style>
</head>
<body>
<canvas id="stars"></canvas>
<div class="glow g1"></div>
<div class="glow g2"></div>
<div class="glow g3"></div>
<div id="progress"></div>
<!-- 顶部导航 -->
<div class="topbar">
<a class="back" href="/">← 返回班史</a>
<span class="nav-tag">{SITE.className} · 班史列表</span>
<a class="back" href="/contribute">📝 投稿</a>
</div>
<div class="wrap">
<header class="post-head container">
<div class="post-date">CHRONICLE · ARCHIVE</div>
<h1>班史列表</h1>
<p class="post-sub">
从初见的那一天起,每一段日子都有了名字 · 共 {total} 篇
</p>
<div class="head-line"></div>
</header>
<main class="container">
{
items.length > 0 ? (
<ol class="post-list">
{items.map((p) => (
<li class="post-item reveal">
<a class="post-link" href={p.url}>
<span class="no">
{String(p.index).padStart(2, "0")}
</span>
<div class="info">
<div class="date">{p.dateLabel}</div>
<div class="t">{p.title}</div>
{p.summary && (
<div class="s">{p.summary}</div>
)}
{p.author && (
<div class="by">{p.author}</div>
)}
</div>
<span class="go">查看 →</span>
</a>
</li>
))}
</ol>
) : (
<p class="empty">班史还是空的,快去投稿吧~</p>
)
}
</main>
<footer>
<div class="sig">{SITE.className} · 永远</div>
<p>愿我们都被世界温柔以待,也永远记得这间教室</p>
<p class="small">© {SITE.className}班史 · 制作于某个想起你们的夜晚</p>
</footer>
</div>
<button id="top" aria-label="回到顶部">↑</button>
<script is:inline src="/js/pages.js"></script>
</body>
</html>
+948
View File
@@ -0,0 +1,948 @@
---
export const prerender = true;
import { SITE } from "../site.config";
// Cloudflare Turnstile 前端 site key(构建时注入,缺失则自动禁用)
const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY ?? "";
const hasTurnstile = turnstileSiteKey.length > 0;
---
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班史投稿</title>
{
hasTurnstile && (
<script
is:inline
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
/>
)
}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #080c17;
--panel: rgba(255, 255, 255, 0.045);
--panel-hi: rgba(255, 255, 255, 0.08);
--line: rgba(230, 192, 104, 0.22);
--gold: #e6c068;
--gold-soft: #f4e0a8;
--blue: #6ea8fe;
--text: #e9ecf5;
--muted: #96a0b8;
--danger: #ff8f8f;
/* 表单控件用的别名,之前只在 button 规则里出现却没定义 */
--acc: var(--gold);
--fg: var(--text);
--bd: rgba(255, 255, 255, 0.14);
--field-bd: rgba(255, 255, 255, 0.12);
--field-bg: rgba(255, 255, 255, 0.03);
}
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, 0.3);
color: #fff;
}
:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
border-radius: 6px;
}
/* ===== 背景 ===== */
#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: 0.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: 0.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 0.1s linear;
box-shadow: 0 0 12px rgba(230, 192, 104, 0.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, 0.62);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.back {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
letter-spacing: 0.1em;
color: var(--gold);
text-decoration: none;
padding: 7px 16px;
border-radius: 100px;
border: 1px solid var(--line);
background: rgba(230, 192, 104, 0.05);
transition: 0.35s;
}
.back:hover {
background: var(--gold);
color: #151006;
transform: translateX(-3px);
}
.nav-tag {
font-size: 12px;
letter-spacing: 0.35em;
color: var(--muted);
}
.topbar-right {
display: inline-flex;
align-items: center;
gap: 16px;
}
.cross-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13.5px;
color: var(--blue);
text-decoration: none;
padding: 7px 16px;
border-radius: 100px;
border: 1px solid rgba(110, 168, 254, 0.35);
background: rgba(110, 168, 254, 0.06);
transition: 0.35s;
}
.cross-link:hover {
background: var(--blue);
color: #0a1120;
transform: translateY(-2px);
}
/* ===== 通用 ===== */
.container {
max-width: 860px;
margin: 0 auto;
padding: 0 1rem;
}
.reveal {
opacity: 0;
transform: translateY(30px);
transition:
opacity 0.9s cubic-bezier(0.2, 0.7, 0.2, 1),
transform 0.9s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.reveal.visible {
opacity: 1;
transform: none;
}
/* ===== 页面主体 ===== */
main {
max-width: 860px;
margin: 0 auto;
/* 顶部导航是 fixed,必须留出高度,否则内容被盖住 */
padding: 112px 1rem 90px;
}
h1 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
font-size: clamp(1.55rem, 4vw, 2.2rem);
line-height: 1.35;
letter-spacing: 0.02em;
color: var(--gold-soft);
}
.sub {
margin: 0.7rem 0 2.2rem;
font-size: 0.9rem;
color: var(--muted);
}
/* ===== 表单结构 ===== */
form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
fieldset {
border: 1px solid var(--line);
border-radius: 16px;
padding: 1.5rem 1.35rem 1.7rem;
background: var(--panel);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
legend {
padding: 0 0.6rem;
font-size: 0.72rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--gold);
}
.row {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 1rem;
}
.row:last-child {
margin-bottom: 0;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.grid > .row {
margin-bottom: 0;
}
label {
font-size: 0.8rem;
letter-spacing: 0.1em;
color: var(--muted);
}
label.req::after {
content: " *";
color: var(--danger);
}
.hint {
font-size: 0.72rem;
color: var(--muted);
opacity: 0.85;
}
/* ===== 输入控件 ===== */
input[type="text"],
input[type="date"],
textarea {
width: 100%;
padding: 0.72rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--field-bd);
background: var(--field-bg);
color: var(--text);
font: inherit;
font-size: 0.92rem;
line-height: 1.7;
transition:
border-color 0.25s,
background 0.25s,
box-shadow 0.25s;
}
input[type="text"]::placeholder,
textarea::placeholder {
color: rgba(150, 160, 184, 0.55);
}
input[type="text"]:hover,
input[type="date"]:hover,
textarea:hover {
border-color: rgba(230, 192, 104, 0.3);
}
input[type="text"]:focus,
input[type="date"]:focus,
textarea:focus {
outline: none;
border-color: var(--gold);
background: var(--panel-hi);
box-shadow: 0 0 0 3px rgba(230, 192, 104, 0.12);
}
textarea {
resize: vertical;
min-height: 3.2rem;
}
input[type="date"] {
color-scheme: dark;
}
input[type="file"] {
font-size: 0.82rem;
color: var(--muted);
padding: 0.2rem 0;
}
input[type="file"]::file-selector-button {
margin-right: 0.8rem;
padding: 0.45rem 0.9rem;
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(230, 192, 104, 0.08);
color: var(--gold);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
transition: 0.3s;
}
input[type="file"]::file-selector-button:hover {
background: var(--gold);
color: #151006;
}
/* ===== 按钮 / 状态 ===== */
button {
padding: 0.6rem 1.35rem;
border-radius: 100px;
border: 1px solid var(--acc);
background: var(--acc);
color: #151006;
font: inherit;
font-weight: 600;
font-size: 0.88rem;
letter-spacing: 0.06em;
cursor: pointer;
transition: 0.3s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(230, 192, 104, 0.25);
}
button.sec {
background: transparent;
color: var(--muted);
border-color: var(--field-bd);
}
button.sec:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.3);
box-shadow: none;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.8rem;
}
#msg {
font-size: 0.85rem;
color: var(--muted);
}
#msg a {
color: var(--gold);
}
/* ===== 预览 ===== */
pre.preview {
background: #0b1020;
color: #d1e7ff;
padding: 0.85rem 1rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
max-height: 300px;
margin: 0;
}
.badge {
display: inline-block;
font-size: 0.68rem;
letter-spacing: 0.12em;
padding: 0.12rem 0.55rem;
border-radius: 999px;
background: rgba(230, 192, 104, 0.12);
border: 1px solid var(--line);
color: var(--gold);
vertical-align: middle;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
main {
padding: 96px 0.9rem 70px;
}
fieldset {
padding: 1.2rem 1rem 1.4rem;
}
}
@media (max-width: 600px) {
.topbar {
padding: 12px 16px;
}
.nav-tag {
display: none;
}
.cross-link {
font-size: 12.5px;
padding: 6px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.reveal {
opacity: 1;
transform: none;
transition: none;
}
button:hover:not(:disabled) {
transform: none;
}
}
.turnstile-box {
max-width: 420px;
}
.turnstile-box .hint {
margin-top: 0.4rem;
}
</style>
</head>
<body>
<!-- 背景元素 -->
<canvas id="stars" aria-hidden="true"></canvas>
<div class="glow g1" aria-hidden="true"></div>
<div class="glow g2" aria-hidden="true"></div>
<div class="glow g3" aria-hidden="true"></div>
<div id="progress" aria-hidden="true"></div>
<div class="wrap">
<div class="topbar">
<a class="back" href="/">← 返回班史</a>
<div class="topbar-right">
<a class="cross-link" href="/music-contribute">🎵 音乐投稿</a>
<span class="nav-tag">{SITE.className} · 班史</span>
</div>
</div>
<main>
<h1 class="reveal">
班史投稿 <span class="badge">chronicle</span>
</h1>
<p class="sub reveal">
提交后会以 PR
形式发到仓库,由管理员审核后合并。需要上传非封面的静态资源(如图片、视频等)请先联系管理员。
</p>
<!-- data-turnstile 供 JS 判断是否需要校验 -->
<form
id="form"
novalidate
data-turnstile={hasTurnstile ? "1" : "0"}
>
<fieldset class="reveal">
<legend>文章</legend>
<div class="row">
<label class="req" for="title">标题</label>
<input
id="title"
name="title"
type="text"
maxlength="200"
autocomplete="off"
required
/>
</div>
<div class="grid">
<div class="row">
<label class="req" for="date">日期</label>
<input
id="date"
name="date"
type="date"
required
/>
</div>
<div class="row">
<label for="author">记录人</label>
<input
id="author"
name="author"
type="text"
maxlength="60"
autocomplete="off"
/>
</div>
</div>
<div class="row">
<label for="summary">摘要</label>
<textarea
id="summary"
name="summary"
rows="2"
maxlength="300"
placeholder="显示在文章页副标题位置,可留空"
></textarea>
</div>
<div class="row">
<label for="cover">封面图</label>
<input
id="cover"
name="cover"
type="file"
accept="image/*"
/>
<p class="hint">可选,≤5&nbsp;MB。</p>
</div>
<div class="row">
<label class="req" for="body">正文</label>
<textarea
id="body"
name="body"
rows="16"
required
placeholder="支持 Markdown"></textarea>
</div>
<div class="row">
<label for="message">留言</label>
<textarea
id="message"
name="message"
rows="4"
maxlength="2000"
placeholder="在 PR 描述中给管理员的留言,可留空"
></textarea>
</div>
</fieldset>
<fieldset class="reveal">
<legend>
预览
<span class="badge" id="filename">YYYYMMDD.md</span>
</legend>
<pre class="preview" id="preview"></pre>
</fieldset>
{
hasTurnstile && (
<div class="turnstile-box reveal">
<div
class="cf-turnstile"
data-sitekey={turnstileSiteKey}
data-theme="dark"
data-language="zh-cn"
data-size="flexible"
/>
<p class="hint">人机验证,提交前需完成。</p>
</div>
)
}
<div class="actions reveal">
<button type="submit" id="submit">提交 PR</button>
<button type="button" class="sec" id="resetBtn"
>清空</button
>
<span id="msg" role="status" aria-live="polite"></span>
</div>
</form>
</main>
</div>
<script>
const $ = (id) => document.getElementById("id".replace("id", id));
const form = $("form");
const dateEl = $("date");
const msg = $("msg");
const preview = $("preview");
const filenameEl = $("filename");
const submitBtn = $("submit");
// 是否启用了 Turnstile(由服务端渲染时决定)
const HAS_TURNSTILE = form.dataset.turnstile === "1";
function getTurnstileToken() {
const el = form.querySelector(
'input[name="cf-turnstile-response"]',
);
return el && typeof el.value === "string" ? el.value : "";
}
function resetTurnstile() {
if (
window.turnstile &&
typeof window.turnstile.reset === "function"
) {
try {
window.turnstile.reset();
} catch {
/* 忽略:widget 可能还没渲染 */
}
}
}
const pad = (n) => String(n).padStart(2, "0");
const todayStr = () => {
const t = new Date();
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
};
dateEl.value = todayStr();
function yyyymmdd(d) {
const [y, m, day] = d.split("-");
return `${y}${m}${day}`;
}
/* ---------- 星空背景 ---------- */
(function stars() {
const canvas = $("stars");
if (!canvas || !canvas.getContext) return;
const ctx = canvas.getContext("2d");
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let list = [];
let raf = null;
function build() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.min(220, Math.round((w * h) / 14000));
list = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
r: Math.random() * 1.2 + 0.3,
p: Math.random() * Math.PI * 2,
s: Math.random() * 0.015 + 0.004,
}));
paint();
}
function paint() {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
for (const st of list) {
const a = 0.18 + Math.abs(Math.sin(st.p)) * 0.62;
ctx.beginPath();
ctx.arc(st.x, st.y, st.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(230, 220, 190, ${a})`;
ctx.fill();
}
}
function loop() {
for (const st of list) st.p += st.s;
paint();
raf = requestAnimationFrame(loop);
}
build();
if (reduce) {
paint();
} else {
loop();
}
let t;
window.addEventListener("resize", () => {
clearTimeout(t);
t = setTimeout(() => {
if (raf) cancelAnimationFrame(raf);
build();
if (!reduce) loop();
}, 150);
});
})();
/* ---------- 顶部进度条 ---------- */
(function progress() {
const bar = $("progress");
if (!bar) return;
let ticking = false;
const update = () => {
const h = document.documentElement;
const max = h.scrollHeight - h.clientHeight;
const p =
max > 0 ? (h.scrollTop || window.scrollY) / max : 0;
bar.style.width = `${Math.min(100, Math.max(0, p * 100))}%`;
ticking = false;
};
window.addEventListener(
"scroll",
() => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
},
{ passive: true },
);
update();
})();
/* ---------- 入场动画 ---------- */
(function reveal() {
const items = document.querySelectorAll(".reveal");
if (!items.length) return;
if (!("IntersectionObserver" in window)) {
items.forEach((el) => el.classList.add("visible"));
return;
}
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add("visible");
io.unobserve(e.target);
}
});
},
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" },
);
items.forEach((el) => io.observe(el));
})();
/* ---------- 表单逻辑 ---------- */
const isRealFile = (f) => f instanceof File && f.size > 0;
function buildFrontmatter(f) {
const lines = ["---"];
lines.push(`title: ${JSON.stringify(f.title || "")}`);
lines.push(`date: ${f.date ? `"${f.date}"` : '""'}`);
if (f.summary)
lines.push(`summary: ${JSON.stringify(f.summary)}`);
if (f.author) lines.push(`author: ${JSON.stringify(f.author)}`);
if (f.cover) lines.push(`cover: ${JSON.stringify(f.cover)}`);
lines.push("---");
return lines.join("\n");
}
function renderPreview() {
const fd = new FormData(form);
const data = Object.fromEntries(fd);
const date = data.date || "";
const fname = date ? `${yyyymmdd(date)}.md` : "YYYYMMDD.md";
filenameEl.textContent = fname;
const coverName = isRealFile(fd.get("cover"))
? fd.get("cover").name
: "";
const fm = buildFrontmatter({
title: data.title,
date,
summary: data.summary,
author: data.author,
cover: coverName ? "/pages/static/…" : "",
});
let out = `${fm}\n\n${data.body || ""}`;
if (coverName) out += `\n\n<!-- 封面文件:${coverName} -->`;
if (data.message) {
out += `\n\n---\n\n留言(PR 描述):\n\n${data.message}`;
}
preview.textContent = out;
}
form.addEventListener("input", renderPreview);
form.addEventListener("change", renderPreview);
renderPreview();
$("resetBtn").addEventListener("click", () => {
form.reset();
dateEl.value = todayStr();
renderPreview();
msg.textContent = "";
msg.removeAttribute("data-state");
// 重置验证,让用户拿到新 token
if (HAS_TURNSTILE) resetTurnstile();
});
function fail(t) {
msg.textContent = "❌ " + t;
msg.dataset.state = "error";
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
msg.textContent = "";
msg.removeAttribute("data-state");
// 客户端校验(和服务端一致)
const fd = new FormData(form);
if (!fd.get("title")?.toString().trim())
return fail("标题不能为空");
if (!fd.get("date")) return fail("日期不能为空");
if (!fd.get("body")?.toString().trim())
return fail("正文不能为空");
const cover = fd.get("cover");
if (isRealFile(cover) && cover.size > 5 * 1024 * 1024) {
return fail("封面图超过 5 MB");
}
// Turnstile:确保拿到 token
if (HAS_TURNSTILE && !getTurnstileToken()) {
return fail("请先完成人机验证");
}
submitBtn.disabled = true;
msg.textContent = "提交中…";
try {
const res = await fetch("/api/submit", {
method: "POST",
body: fd,
});
const j = await res.json().catch(() => ({}));
if (!res.ok)
throw new Error(j.error || `HTTP ${res.status}`);
msg.textContent = "✅ 已创建 PR:";
const a = document.createElement("a");
a.href = j.prUrl || "#";
a.target = "_blank";
a.rel = "noreferrer";
a.textContent = `#${j.number ?? ""}`;
msg.appendChild(a);
msg.dataset.state = "ok";
form.reset();
dateEl.value = todayStr();
renderPreview();
} catch (err) {
fail(err.message || "未知错误");
} finally {
submitBtn.disabled = false;
// token 是一次性的,无论成功失败都要刷新
if (HAS_TURNSTILE) resetTurnstile();
}
});
</script>
</body>
</html>
+256
View File
@@ -0,0 +1,256 @@
---
// 文章列表已移至 /chronicle(班史列表页),首页编年史区块留空
import { SITE } from "../site.config";
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{SITE.className} · 班史</title>
<link rel="stylesheet" href="/css/index.css" />
</head>
<body>
<!-- <Header /> -->
<canvas id="stars"></canvas>
<div class="glow g1"></div>
<div class="glow g2"></div>
<div class="glow g3"></div>
<div id="progress"></div>
<!-- ========== 页眉 ========== -->
<header class="topbar" id="topbar">
<a class="brand" href="/">
<span class="brand-mark">{SITE.brandMark}</span>
<span class="brand-text">{SITE.className} · 班史</span>
</a>
<nav class="topnav" aria-label="页面导航">
<a class="nav-cta" href="#board">留言</a>
<a class="nav-cta" href="/music">♫ 歌单</a>
<a class="nav-cta" href="/chronicle">📖 班史列表</a>
<a class="nav-cta" href="/contribute">📝 投稿</a>
</nav>
</header>
<div class="wrap">
<!-- ========== 首屏 ========== -->
<header class="hero">
<div class="hero-badge">{SITE.heroBadge}</div>
<h1>{SITE.className}</h1>
<p class="sub">
<span id="typed"></span><i class="cursor"></i>
</p>
<div class="hero-line"></div>
<a class="hero-cta" href="/chronicle">📖 打开班史列表</a>
</header>
<!-- ========== 班级简介 ========== -->
<section id="about">
<div class="container">
<div class="intro-grid">
<div class="intro-text reveal">
<span class="sec-tag">About Us</span>
<h2 class="sec-title">
一间教室,<em>三年光阴</em>
</h2>
<p>
那年秋天,几十个互不相识的名字被写进同一张座位表。风扇转过无数个燥热的午后,黑板擦落下的粉笔灰在阳光里飘成一场小雪。我们从陌生到熟悉,从并肩到并肩。
</p>
<p>
这里有早读时此起彼伏的背书声,有课间十分钟的喧闹与追逐,有考试前互相打气的纸条,也有晚自习窗外那片被灯光照亮的夜色。这些琐碎的日常,后来都成了我们回不去的黄金时代。
</p>
<p>谨以此页,记录 {SITE.className} 走过的每一步。</p>
</div>
<div class="stats reveal">
<div class="stat">
<b data-count={SITE.stats.students}>0</b><span>同 学</span>
</div>
<div class="stat">
<b data-count={SITE.stats.years}>0</b><span>年 时 光</span>
</div>
<div class="stat">
<b data-count={SITE.stats.days}>0</b><span>个 日 夜</span>
</div>
<div class="stat">
<b data-count={SITE.stats.memories}>0</b><span>+ 回 忆</span>
</div>
</div>
</div>
</div>
</section>
<!-- ========== 时间轴 ========== -->
<section id="timeline">
<div class="container">
<div class="reveal">
<span class="sec-tag">Chronicle</span>
<h2 class="sec-title">班级<em>编年史</em></h2>
<p class="sec-desc">
从初见的那一天起,每一段日子都有了名字。
</p>
</div>
<!-- 文章列表已移至 /chronicle,此处留空 -->
<div class="timeline"></div>
</div>
</section>
<!-- ========== 班级人物 ========== -->
<section id="people">
<div class="container">
<div class="reveal">
<span class="sec-tag">The Crew</span>
<h2 class="sec-title">班级<em>人物志</em></h2>
<p class="sec-desc">那些撑起这个班的人。</p>
</div>
<div class="people">
<div class="person reveal">
<div class="avatar">👑</div>
<h3>班长</h3>
<div class="role">CAPTAIN</div>
<p class="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>
</section>
<!-- ========== 荣誉 ========== -->
<section id="honor">
<div class="container">
<div class="reveal">
<span class="sec-tag">Honors</span>
<h2 class="sec-title">班级<em>荣誉</em></h2>
<p class="sec-desc">属于我们共同的勋章。</p>
</div>
<div class="honors">
<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—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>
</section>
<!-- ========== 记忆墙 ========== -->
<section id="memory">
<div class="container">
<div class="reveal">
<span class="sec-tag">Memories</span>
<h2 class="sec-title">班级<em>记忆</em></h2>
<p class="sec-desc">
把这里替换成你们的照片吧 —— 将 &lt;div
class="photo"&gt; 换成 &lt;img&gt; 或背景图即可。
</p>
</div>
<div class="gallery">
<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 class="photo reveal"><span>操场黄昏</span></div>
<div class="photo reveal"><span>毕业照</span></div>
</div>
</div>
</section>
<!-- ========== 留言墙 ========== -->
<section id="board">
<div class="container">
<div class="reveal">
<span class="sec-tag">Message Board</span>
<h2 class="sec-title">留<em>言</em>墙</h2>
<p class="sec-desc">写下一句话,送给未来的我们。</p>
</div>
<div class="msg-form reveal">
<input
id="msgName"
type="text"
maxlength="12"
placeholder="你的名字"
/>
<textarea
id="msgText"
maxlength="120"
placeholder={`想对${SITE.classShort}说点什么……`}></textarea>
<button id="msgSend">留 言</button>
</div>
<div class="msg-list" id="msgList"></div>
</div>
</section>
<!-- ========== 页脚 ========== -->
<footer>
<div class="sig">{SITE.className} · 永远</div>
<p>愿我们都被世界温柔以待,也永远记得这间教室</p>
<p class="small">© {SITE.className}班史 · 制作于某个想起你们的夜晚</p>
</footer>
</div>
<button id="top" aria-label="回到顶部">↑</button>
<!-- <Footer /> -->
<script src="/js/index.js" is:inline></script>
</body>
</html>
+864
View File
@@ -0,0 +1,864 @@
---
export const prerender = true;
import { SITE } from "../site.config";
// Cloudflare Turnstile 前端 site key(构建时注入,缺失则自动禁用)
const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY ?? "";
const hasTurnstile = turnstileSiteKey.length > 0;
---
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>音乐投稿 · {SITE.className}</title>
{
hasTurnstile && (
<script
is:inline
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
/>
)
}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #080c17;
--panel: rgba(255, 255, 255, 0.045);
--panel-hi: rgba(255, 255, 255, 0.08);
--line: rgba(230, 192, 104, 0.22);
--gold: #e6c068;
--gold-soft: #f4e0a8;
--blue: #6ea8fe;
--text: #e9ecf5;
--muted: #96a0b8;
--danger: #ff8f8f;
--acc: var(--gold);
--fg: var(--text);
--bd: rgba(255, 255, 255, 0.14);
--field-bd: rgba(255, 255, 255, 0.12);
--field-bg: rgba(255, 255, 255, 0.03);
}
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, 0.3);
color: #fff;
}
:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
border-radius: 6px;
}
/* ===== 背景 ===== */
#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: 0.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: 0.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 0.1s linear;
box-shadow: 0 0 12px rgba(230, 192, 104, 0.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, 0.62);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.back {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
letter-spacing: 0.1em;
color: var(--gold);
text-decoration: none;
padding: 7px 16px;
border-radius: 100px;
border: 1px solid var(--line);
background: rgba(230, 192, 104, 0.05);
transition: 0.35s;
}
.back:hover {
background: var(--gold);
color: #151006;
transform: translateX(-3px);
}
.nav-tag {
font-size: 12px;
letter-spacing: 0.35em;
color: var(--muted);
}
.topbar-right {
display: inline-flex;
align-items: center;
gap: 16px;
}
.cross-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13.5px;
color: var(--blue);
text-decoration: none;
padding: 7px 16px;
border-radius: 100px;
border: 1px solid rgba(110, 168, 254, 0.35);
background: rgba(110, 168, 254, 0.06);
transition: 0.35s;
}
.cross-link:hover {
background: var(--blue);
color: #0a1120;
transform: translateY(-2px);
}
/* ===== 通用 ===== */
main {
max-width: 860px;
margin: 0 auto;
padding: 112px 1rem 90px;
}
h1 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
font-size: clamp(1.55rem, 4vw, 2.2rem);
line-height: 1.35;
letter-spacing: 0.02em;
color: var(--gold-soft);
}
.sub {
margin: 0.7rem 0 2.2rem;
font-size: 0.9rem;
color: var(--muted);
}
/* ===== 表单结构 ===== */
form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
fieldset {
border: 1px solid var(--line);
border-radius: 16px;
padding: 1.5rem 1.35rem 1.7rem;
background: var(--panel);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
legend {
padding: 0 0.6rem;
font-size: 0.72rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--gold);
}
.row {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 1rem;
}
.row:last-child {
margin-bottom: 0;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.grid > .row {
margin-bottom: 0;
}
label {
font-size: 0.8rem;
letter-spacing: 0.1em;
color: var(--muted);
}
label.req::after {
content: " *";
color: var(--danger);
}
.hint {
font-size: 0.72rem;
color: var(--muted);
opacity: 0.85;
}
/* ===== 输入控件 ===== */
input[type="text"],
textarea {
width: 100%;
padding: 0.72rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--field-bd);
background: var(--field-bg);
color: var(--text);
font: inherit;
font-size: 0.92rem;
line-height: 1.7;
transition:
border-color 0.25s,
background 0.25s,
box-shadow 0.25s;
}
input[type="text"]::placeholder,
textarea::placeholder {
color: rgba(150, 160, 184, 0.55);
}
input[type="text"]:hover,
textarea:hover {
border-color: rgba(230, 192, 104, 0.3);
}
input[type="text"]:focus,
textarea:focus {
outline: none;
border-color: var(--gold);
background: var(--panel-hi);
box-shadow: 0 0 0 3px rgba(230, 192, 104, 0.12);
}
textarea {
resize: vertical;
min-height: 3.2rem;
}
/* ===== 按钮 / 状态 ===== */
button {
padding: 0.6rem 1.35rem;
border-radius: 100px;
border: 1px solid var(--acc);
background: var(--acc);
color: #151006;
font: inherit;
font-weight: 600;
font-size: 0.88rem;
letter-spacing: 0.06em;
cursor: pointer;
transition: 0.3s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(230, 192, 104, 0.25);
}
button.sec {
background: transparent;
color: var(--muted);
border-color: var(--field-bd);
}
button.sec:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.3);
box-shadow: none;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.8rem;
}
#msg {
font-size: 0.85rem;
color: var(--muted);
}
#msg a {
color: var(--gold);
}
/* ===== 预览 ===== */
pre.preview {
background: #0b1020;
color: #d1e7ff;
padding: 0.85rem 1rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
max-height: 300px;
margin: 0;
}
.badge {
display: inline-block;
font-size: 0.68rem;
letter-spacing: 0.12em;
padding: 0.12rem 0.55rem;
border-radius: 999px;
background: rgba(230, 192, 104, 0.12);
border: 1px solid var(--line);
color: var(--gold);
vertical-align: middle;
}
.reveal {
opacity: 0;
transform: translateY(30px);
transition:
opacity 0.9s cubic-bezier(0.2, 0.7, 0.2, 1),
transform 0.9s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.reveal.visible {
opacity: 1;
transform: none;
}
.turnstile-box {
max-width: 420px;
}
.turnstile-box .hint {
margin-top: 0.4rem;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
main {
padding: 96px 0.9rem 70px;
}
fieldset {
padding: 1.2rem 1rem 1.4rem;
}
}
@media (max-width: 600px) {
.topbar {
padding: 12px 16px;
}
.nav-tag {
display: none;
}
.cross-link {
font-size: 12.5px;
padding: 6px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.reveal {
opacity: 1;
transform: none;
transition: none;
}
button:hover:not(:disabled) {
transform: none;
}
}
</style>
</head>
<body>
<!-- 背景元素 -->
<canvas id="stars" aria-hidden="true"></canvas>
<div class="glow g1" aria-hidden="true"></div>
<div class="glow g2" aria-hidden="true"></div>
<div class="glow g3" aria-hidden="true"></div>
<div id="progress" aria-hidden="true"></div>
<div class="wrap">
<div class="topbar">
<a class="back" href="/music">← 返回歌单</a>
<div class="topbar-right">
<a class="cross-link" href="/contribute">✍️ 班史投稿</a>
<span class="nav-tag">{SITE.className} · 歌单</span>
</div>
</div>
<main>
<h1 class="reveal">
音乐投稿 <span class="badge">music</span>
</h1>
<p class="sub reveal">
推荐一首属于我们班的歌,提交后会以 PR
形式发到仓库,由管理员审核后合并进歌单。
</p>
<!-- data-turnstile 供 JS 判断是否需要校验 -->
<form
id="form"
novalidate
data-turnstile={hasTurnstile ? "1" : "0"}
>
<fieldset class="reveal">
<legend>歌曲</legend>
<input type="hidden" name="type" value="music" />
<div class="grid">
<div class="row">
<label class="req" for="title">歌名</label>
<input
id="title"
name="title"
type="text"
maxlength="200"
autocomplete="off"
required
placeholder="例:起风了"
/>
</div>
<div class="row">
<label class="req" for="artist">歌手</label>
<input
id="artist"
name="artist"
type="text"
maxlength="120"
autocomplete="off"
required
placeholder="例:买辣椒也用券"
/>
</div>
</div>
<div class="row">
<label for="link">播放链接</label>
<input
id="link"
name="link"
type="text"
maxlength="500"
autocomplete="off"
placeholder="网易云 / QQ音乐 / B站 等地址,可留空"
/>
<p class="hint">须以 http:// 或 https:// 开头。</p>
</div>
<div class="row">
<label for="note">回忆备注</label>
<textarea
id="note"
name="note"
rows="2"
maxlength="500"
placeholder="这首歌和班级的故事,会显示在歌单里,可留空"></textarea>
</div>
<div class="row">
<label for="author">推荐人</label>
<input
id="author"
name="author"
type="text"
maxlength="60"
autocomplete="off"
placeholder="你的名字,可留空"
/>
</div>
<div class="row">
<label for="message">留言</label>
<textarea
id="message"
name="message"
rows="4"
maxlength="2000"
placeholder="在 PR 描述中给管理员的留言,可留空"></textarea>
</div>
</fieldset>
<fieldset class="reveal">
<legend>
预览
<span class="badge" id="filename">song.json</span>
</legend>
<pre class="preview" id="preview"></pre>
</fieldset>
{
hasTurnstile && (
<div class="turnstile-box reveal">
<div
class="cf-turnstile"
data-sitekey={turnstileSiteKey}
data-theme="dark"
data-language="zh-cn"
data-size="flexible"
/>
<p class="hint">人机验证,提交前需完成。</p>
</div>
)
}
<div class="actions reveal">
<button type="submit" id="submit">提交 PR</button>
<button type="button" class="sec" id="resetBtn"
>清空</button
>
<span id="msg" role="status" aria-live="polite"></span>
</div>
</form>
</main>
</div>
<script is:inline>
const $ = (id) => document.getElementById(id);
const form = $("form");
const msg = $("msg");
const preview = $("preview");
const filenameEl = $("filename");
const submitBtn = $("submit");
// 是否启用了 Turnstile(由服务端渲染时决定)
const HAS_TURNSTILE = form.dataset.turnstile === "1";
function getTurnstileToken() {
const el = form.querySelector(
'input[name="cf-turnstile-response"]',
);
return el && typeof el.value === "string" ? el.value : "";
}
function resetTurnstile() {
if (
window.turnstile &&
typeof window.turnstile.reset === "function"
) {
try {
window.turnstile.reset();
} catch {
/* 忽略:widget 可能还没渲染 */
}
}
}
/* ---------- 星空背景 ---------- */
(function stars() {
const canvas = $("stars");
if (!canvas || !canvas.getContext) return;
const ctx = canvas.getContext("2d");
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let list = [];
let raf = null;
function build() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.min(220, Math.round((w * h) / 14000));
list = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
r: Math.random() * 1.2 + 0.3,
p: Math.random() * Math.PI * 2,
s: Math.random() * 0.015 + 0.004,
}));
paint();
}
function paint() {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
for (const st of list) {
const a = 0.18 + Math.abs(Math.sin(st.p)) * 0.62;
ctx.beginPath();
ctx.arc(st.x, st.y, st.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(230, 220, 190, ${a})`;
ctx.fill();
}
}
function loop() {
for (const st of list) st.p += st.s;
paint();
raf = requestAnimationFrame(loop);
}
build();
if (reduce) {
paint();
} else {
loop();
}
let t;
window.addEventListener("resize", () => {
clearTimeout(t);
t = setTimeout(() => {
if (raf) cancelAnimationFrame(raf);
build();
if (!reduce) loop();
}, 150);
});
})();
/* ---------- 顶部进度条 ---------- */
(function progress() {
const bar = $("progress");
if (!bar) return;
let ticking = false;
const update = () => {
const h = document.documentElement;
const max = h.scrollHeight - h.clientHeight;
const p =
max > 0 ? (h.scrollTop || window.scrollY) / max : 0;
bar.style.width = `${Math.min(100, Math.max(0, p * 100))}%`;
ticking = false;
};
window.addEventListener(
"scroll",
() => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
},
{ passive: true },
);
update();
})();
/* ---------- 入场动画 ---------- */
(function reveal() {
const items = document.querySelectorAll(".reveal");
if (!items.length) return;
if (!("IntersectionObserver" in window)) {
items.forEach((el) => el.classList.add("visible"));
return;
}
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add("visible");
io.unobserve(e.target);
}
});
},
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" },
);
items.forEach((el) => io.observe(el));
})();
/* ---------- 预览 ---------- */
function renderPreview() {
const fd = new FormData(form);
const g = (k) => (fd.get(k) || "").toString().trim();
const obj = {};
const title = g("title");
const artist = g("artist");
if (title) obj.title = title;
if (artist) obj.artist = artist;
if (g("link")) obj.link = g("link");
if (g("note")) obj.note = g("note");
if (g("author")) obj.author = g("author");
filenameEl.textContent = "song.json";
preview.textContent = JSON.stringify(obj, null, 2);
}
form.addEventListener("input", renderPreview);
form.addEventListener("change", renderPreview);
renderPreview();
$("resetBtn").addEventListener("click", () => {
form.reset();
renderPreview();
msg.textContent = "";
msg.removeAttribute("data-state");
// 重置验证,让用户拿到新 token
if (HAS_TURNSTILE) resetTurnstile();
});
function fail(t) {
msg.textContent = "❌ " + t;
msg.dataset.state = "error";
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
msg.textContent = "";
msg.removeAttribute("data-state");
// 客户端校验(和服务端一致)
const fd = new FormData(form);
if (!fd.get("title")?.toString().trim())
return fail("歌名不能为空");
if (!fd.get("artist")?.toString().trim())
return fail("歌手不能为空");
const link = fd.get("link")?.toString().trim();
if (link && !/^https?:\/\//i.test(link))
return fail("链接必须以 http:// 或 https:// 开头");
// Turnstile:确保拿到 token
if (HAS_TURNSTILE && !getTurnstileToken()) {
return fail("请先完成人机验证");
}
submitBtn.disabled = true;
msg.textContent = "提交中…";
try {
const res = await fetch("/api/submit", {
method: "POST",
body: fd,
});
const j = await res.json().catch(() => ({}));
if (!res.ok)
throw new Error(j.error || `HTTP ${res.status}`);
msg.textContent = "✅ 已创建 PR:";
const a = document.createElement("a");
a.href = j.prUrl || "#";
a.target = "_blank";
a.rel = "noreferrer";
a.textContent = `#${j.number ?? ""}`;
msg.appendChild(a);
msg.dataset.state = "ok";
form.reset();
renderPreview();
} catch (err) {
fail(err.message || "未知错误");
} finally {
submitBtn.disabled = false;
// token 是一次性的,无论成功失败都要刷新
if (HAS_TURNSTILE) resetTurnstile();
}
});
</script>
</body>
</html>
+541
View File
@@ -0,0 +1,541 @@
---
export const prerender = true;
import { getCollection } from "astro:content";
import { SITE } from "../site.config";
// 歌单完全来自投稿:src/songs/*.json(投稿 PR 合并后自动显示)
type Song = {
title: string;
artist: string;
note?: string;
link?: string;
author?: string;
};
let songs: Song[] = [];
try {
songs = (await getCollection("songs"))
.sort((a, b) => (b.data.date ?? "").localeCompare(a.data.date ?? ""))
.map((e) => ({
title: e.data.title,
artist: e.data.artist,
note: e.data.note,
link: e.data.link,
author: e.data.author,
}));
} catch {
// 目录还不存在或为空时忽略(目录不存在时 glob loader 也可能抛错)
songs = [];
}
const total = songs.length;
// 随机抽取用的数据(静态预渲染,直接内联 JSON 给浏览器)
// 转义 "<" ,避免歌名/备注里出现脚本结束标签时提前闭合 script
const songsJson = JSON.stringify(songs).replace(/</g, "\\u003c");
---
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>班级歌单 · {SITE.className}</title>
<link rel="stylesheet" href="/css/pages.css" />
<style>
.song-list {
list-style: none;
padding: 0;
margin: 0;
}
.song {
display: flex;
align-items: center;
gap: 18px;
padding: 18px 20px;
border: 1px solid rgba(230, 192, 104, 0.16);
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
margin-bottom: 14px;
transition:
transform 0.25s ease,
border-color 0.25s ease,
background 0.25s ease;
}
.song:hover {
transform: translateX(6px);
border-color: rgba(230, 192, 104, 0.45);
background: rgba(255, 255, 255, 0.06);
}
.song .no {
font-size: 0.85rem;
color: var(--muted, #96a0b8);
font-variant-numeric: tabular-nums;
min-width: 2.2em;
text-align: right;
}
.song .info {
flex: 1;
min-width: 0;
}
.song .t {
font-size: 1.05rem;
color: var(--text, #e9ecf5);
font-weight: 600;
}
.song .a {
font-size: 0.85rem;
color: var(--muted, #96a0b8);
margin-left: 10px;
}
.song .n {
font-size: 0.85rem;
color: var(--gold-soft, #f4e0a8);
margin-top: 4px;
}
.song .by {
font-size: 0.78rem;
color: var(--muted, #96a0b8);
margin-top: 2px;
}
.song .by::before {
content: "🎧 推荐:";
opacity: 0.8;
}
.song .play {
font-size: 0.8rem;
color: var(--gold, #e6c068);
text-decoration: none;
border: 1px solid rgba(230, 192, 104, 0.35);
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
transition: background 0.2s ease;
}
.song .play:hover {
background: rgba(230, 192, 104, 0.15);
}
.song.reveal {
opacity: 0;
transform: translateX(24px);
transition:
opacity 0.55s ease,
transform 0.55s cubic-bezier(0.22, 0.61, 0.36, 1),
border-color 0.25s ease,
background 0.25s ease;
}
.song.reveal.visible {
opacity: 1;
transform: translateX(0);
}
@media (prefers-reduced-motion: reduce) {
.song.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
.empty {
color: var(--muted, #96a0b8);
text-align: center;
padding: 40px 0;
}
/* ===== 随机抽取 ===== */
.draw-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
margin-top: 22px;
}
.draw-btn {
font: inherit;
font-size: 0.95rem;
font-weight: 600;
color: #241a05;
background: linear-gradient(135deg, #f4e0a8, #e6c068 55%, #d3a94b);
border: none;
border-radius: 999px;
padding: 10px 26px;
cursor: pointer;
box-shadow: 0 10px 26px rgba(230, 192, 104, 0.25);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
}
.draw-btn:hover {
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(230, 192, 104, 0.38);
filter: brightness(1.05);
}
.draw-btn:active {
transform: translateY(0);
}
.draw-btn:focus-visible {
outline: 2px solid var(--gold-soft, #f4e0a8);
outline-offset: 3px;
}
.draw-tip {
font-size: 0.82rem;
color: var(--muted, #96a0b8);
}
.draw-tip kbd {
font-family: inherit;
font-size: 0.75rem;
color: var(--gold-soft, #f4e0a8);
border: 1px solid rgba(230, 192, 104, 0.4);
border-bottom-width: 2px;
border-radius: 5px;
padding: 1px 6px;
}
.shuffle-tip {
margin-top: 14px;
text-align: center;
font-size: 0.82rem;
color: var(--muted, #96a0b8);
}
.shuffle-tip #shuffle-day {
color: var(--gold-soft, #f4e0a8);
font-variant-numeric: tabular-nums;
}
.draw-dialog {
border: none;
padding: 0;
background: transparent;
color: var(--text, #e9ecf5);
/* pages.css 的 * { margin: 0 } 覆盖了浏览器给弹窗的 margin: auto,
会让 inset: 0 的弹窗贴到左上角,这里显式补回以保持居中 */
position: fixed;
inset: 0;
margin: auto;
width: min(92vw, 460px);
max-width: 460px;
/* 高度按内容收缩,配合 margin:auto 才能真正上下居中 */
height: fit-content;
overflow: visible;
}
.draw-dialog::backdrop {
background: rgba(4, 7, 14, 0.72);
backdrop-filter: blur(3px);
}
.draw-card {
text-align: center;
padding: 30px 26px 22px;
border: 1px solid rgba(230, 192, 104, 0.35);
border-radius: 20px;
background:
radial-gradient(
120% 100% at 50% 0%,
rgba(230, 192, 104, 0.14),
transparent 60%
),
linear-gradient(160deg, #171b2b, #0d1020);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.62);
/* 屏幕很矮时在卡片内部滚动,弹窗本身保持居中且不裁掉外阴影 */
max-height: calc(100vh - 24px);
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.draw-dialog.open-in .draw-card {
animation: drawIn 0.32s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.draw-eyebrow {
font-size: 0.72rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--gold, #e6c068);
opacity: 0.85;
}
.draw-song {
margin: 12px 0 6px;
font-size: 1.45rem;
line-height: 1.4;
color: var(--text, #e9ecf5);
word-break: break-word;
transition:
opacity 0.15s ease,
filter 0.15s ease;
}
.draw-artist {
font-size: 0.9rem;
color: var(--muted, #96a0b8);
}
.draw-note {
margin-top: 12px;
font-size: 0.85rem;
color: var(--gold-soft, #f4e0a8);
white-space: pre-wrap;
word-break: break-word;
}
.draw-by {
margin-top: 6px;
font-size: 0.78rem;
color: var(--muted, #96a0b8);
}
.draw-by::before {
content: "🎧 推荐:";
opacity: 0.8;
}
.draw-dialog.rolling .draw-song {
opacity: 0.75;
filter: blur(1px);
}
.draw-dialog.pop {
animation: drawPop 0.46s cubic-bezier(0.2, 1.3, 0.4, 1);
}
.draw-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 22px;
}
.draw-actions .play {
font-size: 0.8rem;
color: var(--gold, #e6c068);
text-decoration: none;
border: 1px solid rgba(230, 192, 104, 0.4);
padding: 6px 14px;
border-radius: 999px;
transition: background 0.2s ease;
}
.draw-actions .play:hover {
background: rgba(230, 192, 104, 0.15);
}
.draw-actions button {
font: inherit;
font-size: 0.8rem;
padding: 6px 14px;
border-radius: 999px;
cursor: pointer;
transition:
background 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
.draw-actions .primary {
color: #241a05;
font-weight: 600;
background: linear-gradient(135deg, #f4e0a8, #e6c068);
border: 1px solid transparent;
}
.draw-actions .primary:hover {
filter: brightness(1.06);
}
.draw-actions .ghost {
color: var(--muted, #96a0b8);
background: transparent;
border: 1px solid rgba(150, 160, 184, 0.35);
}
.draw-actions .ghost:hover {
color: var(--text, #e9ecf5);
border-color: rgba(150, 160, 184, 0.7);
}
.draw-actions button:focus-visible {
outline: 2px solid var(--gold-soft, #f4e0a8);
outline-offset: 2px;
}
.draw-hint {
margin-top: 16px;
font-size: 0.72rem;
color: var(--muted, #96a0b8);
opacity: 0.75;
}
.draw-dialog [hidden] {
display: none !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.song.flash {
border-color: rgba(230, 192, 104, 0.85);
background: rgba(230, 192, 104, 0.12);
animation: flashGlow 1.8s ease;
}
@keyframes drawIn {
from {
opacity: 0;
transform: translateY(14px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes drawPop {
0% {
transform: scale(1);
}
45% {
transform: scale(1.035);
}
100% {
transform: scale(1);
}
}
@keyframes flashGlow {
0%,
100% {
box-shadow: 0 0 0 rgba(230, 192, 104, 0);
}
35% {
box-shadow: 0 0 26px rgba(230, 192, 104, 0.45);
}
}
@media (prefers-reduced-motion: reduce) {
.draw-dialog.open-in .draw-card,
.draw-dialog.pop,
.song.flash {
animation: none;
}
}
</style>
</head>
<body>
<canvas id="stars"></canvas>
<div class="glow g1"></div>
<div class="glow g2"></div>
<div class="glow g3"></div>
<div id="progress"></div>
<!-- 顶部导航 -->
<div class="topbar">
<a class="back" href="/">← 返回班史</a>
<span class="nav-tag">{SITE.className} · 歌单</span>
<a class="back" href="/music-contribute">🎵 投稿音乐</a>
</div>
<div class="wrap">
<header class="post-head container">
<div class="post-date">MUSIC · PLAYLIST</div>
<h1>班级歌单</h1>
<p class="post-sub">
那些在教室里循环过的旋律,共 {total} 首
</p>
{
total > 0 && (
<div class="draw-bar">
<button id="draw-btn" class="draw-btn" type="button">
🎲 随机抽一首
</button>
<span class="draw-tip">
不知道听什么?交给随机 · 也可按 <kbd>R</kbd>
</span>
</div>
)
}
{
total > 1 && (
<p id="shuffle-tip" class="shuffle-tip" hidden>
🔀 歌曲顺序每天随机一次 · 今日排序:<span
id="shuffle-day"></span>
</p>
)
}
<div class="head-line"></div>
</header>
<main class="container">
{
songs.length > 0 ? (
<ol class="song-list">
{songs.map((s, i) => (
<li class="song reveal" data-index={i}>
<span class="no">
{String(i + 1).padStart(2, "0")}
</span>
<div class="info">
<div>
<span class="t">{s.title}</span>
<span class="a">{s.artist}</span>
</div>
{s.note && <div class="n">{s.note}</div>}
{s.author && <div class="by">{s.author}</div>}
</div>
{s.link && (
<a
class="play"
href={s.link}
target="_blank"
rel="noopener noreferrer"
>
♫ 播放
</a>
)}
</li>
))}
</ol>
) : (
<p class="empty">歌单还是空的,快去投稿吧~</p>
)
}
</main>
<!-- 随机抽取结果 -->
<dialog
id="draw-dialog"
class="draw-dialog"
aria-labelledby="draw-song"
>
<div class="draw-card">
<div class="draw-eyebrow">随机抽取</div>
<h2 id="draw-song" class="draw-song">—</h2>
<div id="draw-artist" class="draw-artist"></div>
<p id="draw-note" class="draw-note" hidden></p>
<p id="draw-by" class="draw-by" hidden></p>
<div class="draw-actions">
<a
id="draw-play"
class="play"
target="_blank"
rel="noopener noreferrer"
hidden>♫ 播放</a
>
<button id="draw-locate" class="ghost" type="button">
在歌单中查看
</button>
<button id="draw-again" class="primary" type="button">
再抽一次
</button>
<button id="draw-close" class="ghost" type="button">
关闭
</button>
</div>
<p class="draw-hint">按 R 换一首 · Esc 关闭</p>
<p id="draw-announce" class="sr-only" aria-live="polite"></p>
</div>
</dialog>
<footer>
<div class="sig">{SITE.className} · 永远</div>
<p>愿我们都被世界温柔以待,也永远记得这间教室</p>
<p class="small">© {SITE.className}班史 · 制作于某个想起你们的夜晚</p>
</footer>
</div>
<script
type="application/json"
id="songs-data"
set:html={songsJson}
></script>
<!-- 先按当天顺序重排歌单,再启动 pages.js 的入场观察,
否则观察者会先按旧顺序放出一批动画,重排后就乱序了 -->
<script is:inline src="/js/music-shuffle.js"></script>
<script is:inline src="/js/pages.js"></script>
<script is:inline src="/js/music-random.js"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
---
// 动态路由:src/content/*.md → /pages/<文件名去扩展名>
// 例如 src/content/20250101.md → /pages/20250101
import { getCollection, render } from 'astro:content';
import PostLayout from '../../layouts/PostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('chronicle');
const sorted = posts.sort(
(a, b) => a.data.date.valueOf() - b.data.date.valueOf()
);
return Promise.all(
sorted.map(async (entry, i) => {
const slug = entry.id.split('/').pop()!; // 去掉年月目录前缀
const prev = i > 0 ? sorted[i - 1] : null;
const next = i < sorted.length - 1 ? sorted[i + 1] : null;
return {
params: { slug },
props: {
entry,
index: i,
total: sorted.length,
prev: prev
? {
url: `/pages/${prev.id.split('/').pop()}`,
title: prev.data.title,
}
: null,
next: next
? {
url: `/pages/${next.id.split('/').pop()}`,
title: next.data.title,
}
: null,
rendered: await render(entry),
},
};
})
);
}
const { entry, index, total, prev, next, rendered } = Astro.props;
---
<PostLayout
entry={entry}
index={index}
total={total}
prev={prev}
next={next}
rendered={rendered}
/>
+51
View File
@@ -0,0 +1,51 @@
/* ================= markdown → HTML 时自动补 .reveal =================
Astro 7 默认使用 Sätteri 处理器,这里是一个 Sätteri 的 hast 插件:
在 markdown 转成 HTML 之后,给正文最外层的块级元素补上 .reveal,
交给 /js/pages.js 的 IntersectionObserver 做错峰入场。
这样文章的 markdown 源文件保持干净,不用手写任何 class。 */
// 需要入场动画的块级元素。
const BLOCK_TAGS = [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"ul",
"ol",
"blockquote",
"pre",
"table",
"figure",
"details",
"summary",
];
/** 把 "reveal" 追加到已有 class 上(class 可能是数组或字符串)。 */
function withReveal(current) {
const list = Array.isArray(current)
? current.slice()
: typeof current === "string" && current.trim()
? current.trim().split(/\s+/)
: [];
if (!list.includes("reveal")) list.push("reveal");
return list;
}
export default {
name: "reveal",
element: {
// 由 Sätteri 在 Rust 侧按标签名过滤,只有这些元素会进到 visit
filter: BLOCK_TAGS,
visit(node, ctx) {
// 只标注正文最外层:嵌套的 li、blockquote 里的 p、内层 ul 等
// 跟着外层一起出现,再各自加一层会重复播放、时序也会互相错开。
const parent = ctx.parent(node);
if (parent && parent.type !== "root" && !(node.tagName === "summary" && parent.tagName === "details")) return;
ctx.setProperty(node, "className", withReveal(node.properties?.className));
},
},
};
+22
View File
@@ -0,0 +1,22 @@
/** 班级信息:改这一个文件即可完成大部分定制,各页面会自动引用。 */
export const SITE = {
/** 班级全名,如 "30届1班" */
className: "XX届X班",
/** 班级简称,用于留言框占位符等口语场景,如 "1班" */
classShort: "X班",
/** 顶栏 logo 方块里显示的字符,如 "1" */
brandMark: "X",
/** 首屏徽章文字,如 "CLASS 1 · FOREVER" */
heroBadge: "CLASS XX · FOREVER",
/** 首页「班级简介」区块的统计数字 */
stats: {
/** 同学 */
students: 40,
/** 年时光 */
years: 3,
/** 个日夜 */
days: 1000,
/** + 回忆(值为 999 时会自动显示加号) */
memories: 999,
},
} as const;
+8
View File
@@ -0,0 +1,8 @@
{
"title": "示例歌曲(删掉我)",
"artist": "示例歌手",
"date": "20250101",
"link": "https://example.com/",
"note": "这是一条示例投稿,展示歌单条目的显示效果,正式使用前请删除本文件。",
"author": "站长"
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}