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

|
||||||
|
|
||||||
|
封面在 frontmatter 的 `cover` 字段设置:图片放到 `public/pages/static/` 目录,引用时写 `/pages/static/文件名`。
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 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>
|
||||||
@@ -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">
|
||||||
|
把这里替换成你们的照片吧 —— 将 <div
|
||||||
|
class="photo"> 换成 <img> 或背景图即可。
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"title": "示例歌曲(删掉我)",
|
||||||
|
"artist": "示例歌手",
|
||||||
|
"date": "20250101",
|
||||||
|
"link": "https://example.com/",
|
||||||
|
"note": "这是一条示例投稿,展示歌单条目的显示效果,正式使用前请删除本文件。",
|
||||||
|
"author": "站长"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user