merge: 合并 GitHub Webhook 部署到 master

This commit is contained in:
admin committed 2026-10-20 14:23:46 +08:00
commit 7ba7968047
22 files changed
+3747 -307

No files matched your search

+21 -37
View File
@@ -1,48 +1,32 @@
name: Deploy WeReadBoard name: Verify WeReadBoard
on: on:
push: push:
branches: branches:
- master - master
- dev
pull_request:
branches:
- master
- dev
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency:
group: deploy-master
cancel-in-progress: true
steps: steps:
- name: Prepare SSH - name: Checkout
run: | uses: actions/checkout@v4
mkdir -p ~/.ssh - name: Set up Python
printf '%s\n' "${{ secrets.SERVER_SSH_KEY }}" > ~/.ssh/deploy_key uses: actions/setup-python@v5
chmod 600 ~/.ssh/deploy_key with:
python-version: '3.11'
- name: Deploy - name: Install dependencies
env: run: python -m pip install -r requirements.txt
SERVER_HOST: ${{ secrets.SERVER_HOST }} - name: Run Python tests
SERVER_PORT: ${{ secrets.SERVER_PORT }} run: python -m unittest discover -s tests -v
SERVER_USER: ${{ secrets.SERVER_USER }} - name: Run frontend tests
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }} run: node --test tests/goals_frontend.test.cjs tests/weapi_fallback.test.cjs
SERVICE_NAME: ${{ vars.SERVICE_NAME }} - name: Check deployment scripts
DEPLOY_BRANCH: master run: bash tests/deploy_master_only.test.sh && bash -n deploy.sh
run: |
if [ -z "$SERVER_PORT" ]; then
SERVER_PORT="22"
fi
if [ -z "$DEPLOY_PATH" ]; then
DEPLOY_PATH="/opt/weread-board/WeReadBoard"
fi
if [ -z "$SERVICE_NAME" ]; then
SERVICE_NAME="weread.service"
fi
ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" \
"cd '$DEPLOY_PATH' &&
git fetch origin &&
git checkout '$DEPLOY_BRANCH' &&
git pull --ff-only origin '$DEPLOY_BRANCH' &&
chmod +x ./deploy.sh &&
SKIP_GIT_PULL=1 DEPLOY_BRANCH='$DEPLOY_BRANCH' SERVICE_NAME='$SERVICE_NAME' ./deploy.sh"
+3 -3
View File
@@ -64,13 +64,13 @@ PORT=8080 ./start_dashboard.sh # 或指定端口
## 自动部署 ## 自动部署
服务器部署使用根目录的 `deploy.sh`: 服务器部署使用根目录的 `deploy.sh`,由 GitHub Webhook 通知服务器触发:
```bash ```bash
DEPLOY_BRANCH=master ./deploy.sh DEPLOY_BRANCH=master ./deploy.sh
``` ```
脚本只允许拉取 `master` 分支,随后自动创建 `.venv`、安装 `requirements.txt`、执行数据库迁移并重启 `weread.service`。GitHub Actions 配置在 `.github/workflows/deploy.yml`,只监听 `master` 分支,详细配置见 [master 自动部署](docs/deploy-master.md)。 脚本只允许拉取 `master` 分支,随后自动创建 `.venv`、安装 `requirements.txt`、执行数据库迁移并重启 `weread.service`。GitHub Actions 只做代码验证,不再通过 SSH 登录服务器;详细配置见 [master 自动部署](docs/deploy-master.md)。
运行端口、Python 环境、HTTPS 证书和接口故障的诊断方法见 [日常维护与故障排查](docs/troubleshooting.md)。 运行端口、Python 环境、HTTPS 证书和接口故障的诊断方法见 [日常维护与故障排查](docs/troubleshooting.md)。
@@ -84,7 +84,7 @@ DEPLOY_BRANCH=master ./deploy.sh
| 偏好分类 | 阅读分类占比饼图 | | 偏好分类 | 阅读分类占比饼图 |
| 偏好作者 | 阅读最多的作者排行 | | 偏好作者 | 阅读最多的作者排行 |
| 读书排行 | 阅读时长最久的书籍 TOP 20 | | 读书排行 | 阅读时长最久的书籍 TOP 20 |
| 书架 | 全部书架书籍展示,支持分类筛选和已读完筛选 | | 书架 | 每页 48 本,底部页码分页,支持按最近阅读、书名或作者排序 |
| 笔记概览 | 有笔记的书籍及划线/想法/书签统计 | | 笔记概览 | 有笔记的书籍及划线/想法/书签统计 |
## 数据库表结构 ## 数据库表结构
+7
View File
@@ -0,0 +1,7 @@
# 放入现有 laohang.space HTTPS server 块中。
location = /github/webhook {
proxy_pass http://127.0.0.1:9001/github/webhook;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=WeReadBoard GitHub Webhook Receiver
After=network.target
[Service]
Type=simple
User=weread
WorkingDirectory=/opt/weread-board/WeReadBoard
EnvironmentFile=/etc/weread-webhook.env
ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python deploy_webhook.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""接收 GitHub push Webhook,并触发服务器上的固定部署脚本。"""
import hmac
import os
import subprocess
import threading
from hashlib import sha256
from flask import Flask, jsonify, request
DEFAULT_PROJECT_DIR = "/opt/weread-board/WeReadBoard"
DEFAULT_DEPLOY_SCRIPT = "deploy.sh"
DEFAULT_REPOSITORY = "KingHWJ/WeReadBoard"
DEFAULT_BRANCH = "master"
def create_app(config=None):
app = Flask(__name__)
app.config.from_mapping(
WEBHOOK_SECRET=os.environ.get("GITHUB_WEBHOOK_SECRET", ""),
REPOSITORY=os.environ.get("GITHUB_REPOSITORY", DEFAULT_REPOSITORY),
BRANCH=DEFAULT_BRANCH,
PROJECT_DIR=os.environ.get("DEPLOY_PROJECT_DIR", DEFAULT_PROJECT_DIR),
DEPLOY_SCRIPT=os.environ.get("DEPLOY_SCRIPT", DEFAULT_DEPLOY_SCRIPT),
)
if config:
app.config.update(config)
if not app.config["WEBHOOK_SECRET"]:
raise RuntimeError("GITHUB_WEBHOOK_SECRET 未配置")
if app.config["BRANCH"] != DEFAULT_BRANCH:
raise RuntimeError("Webhook 只允许部署 master 分支")
deployment_lock = threading.Lock()
deployment_process = None
def signature_valid(body, signature):
if not signature or not signature.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
app.config["WEBHOOK_SECRET"].encode("utf-8"), body, sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
def start_deployment():
nonlocal deployment_process
with deployment_lock:
if deployment_process is not None and deployment_process.poll() is None:
return False
env = os.environ.copy()
env["DEPLOY_BRANCH"] = DEFAULT_BRANCH
script = app.config["DEPLOY_SCRIPT"]
if not os.path.isabs(script):
script = os.path.join(app.config["PROJECT_DIR"], script)
deployment_process = subprocess.Popen(
[script],
cwd=app.config["PROJECT_DIR"],
env=env,
start_new_session=True,
)
return True
@app.get("/health")
def health():
return jsonify(ok=True)
@app.post("/github/webhook")
def github_webhook():
body = request.get_data(cache=True)
if not signature_valid(body, request.headers.get("X-Hub-Signature-256", "")):
return jsonify(error="签名无效"), 401
if request.headers.get("X-GitHub-Event") != "push":
return jsonify(error="只接受 push 事件"), 202
payload = request.get_json(silent=True) or {}
repository = (payload.get("repository") or {}).get("full_name")
if repository != app.config["REPOSITORY"]:
return jsonify(error="仓库不匹配"), 403
if payload.get("ref") != f"refs/heads/{app.config['BRANCH']}" or payload.get("deleted"):
return jsonify(error="分支不匹配"), 202
if not start_deployment():
return jsonify(status="already_deploying"), 202
return jsonify(status="accepted"), 202
return app
if __name__ == "__main__":
application = create_app()
application.run(
host=os.environ.get("WEBHOOK_HOST", "127.0.0.1"),
port=int(os.environ.get("WEBHOOK_PORT", "9001")),
)
+3 -1
View File
@@ -7,7 +7,7 @@
| 数据 | 来源 | 说明 | | 数据 | 来源 | 说明 |
|------|------|------| |------|------|------|
| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间 | | 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间和置顶标记 |
| 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 | | 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 |
| 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 | | 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 |
| 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 | | 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 |
@@ -17,6 +17,8 @@
MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。 MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。
当前书架页一次读取书架电子书,在前端按状态筛选后每页显示 48 本。排序选项为最近阅读(默认)、书名升序、书名降序和作者升序;置顶书籍始终排在前面。接口仍支持 `limit` / `offset` 参数供其他调用者分页,书架页使用全量结果以供书架、阅读目标和发现功能共享。
## 接口清单 ## 接口清单
| 方法 | 路径 | 参数 | 说明 | | 方法 | 路径 | 参数 | 说明 |
+10 -2
View File
@@ -43,11 +43,11 @@
## 部署流程 ## 部署流程
GitHub Actions 的 `.github/workflows/deploy.yml` 只监听 `master` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。手动触发也固定部署 `master`,部署脚本会拒绝其他分支。 GitHub Actions 的 `.github/workflows/deploy.yml` 只做验证,不登录服务器。`master` push 后由 GitHub Webhook 向 `https://laohang.space/github/webhook` 发送 JSON,服务器上的 `deploy_webhook.py` 校验 HMAC-SHA256、仓库和 `master` 分支,再在固定目录调用 `deploy.sh`。Webhook 不接受请求体中的命令,部署脚本路径由服务器环境变量固定。
`deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,按文件名顺序执行 `migrations/*.sql`,最后重启 `weread.service`。 `deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,按文件名顺序执行 `migrations/*.sql`,最后重启 `weread.service`。
例:用户将已验收代码合并并推送到 `master`,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署。 例:用户将已验收代码合并并推送到 `master`,GitHub 发送签名请求;服务器确认仓库为 `KingHWJ/WeReadBoard` 且 ref 为 `refs/heads/master` 后,在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署,错误签名也不会执行脚本。
## 正式看板登录认证 ## 正式看板登录认证
@@ -81,6 +81,14 @@ GitHub Actions 的 `.github/workflows/deploy.yml` 只监听 `master` 分支。
目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 280 px 起步,并缩小封面预览和上下间距。例如,一个包含 6 本书、已读完 2 本的月度目标仍展示原有完成数和进度,只减少卡片占位。页面渲染、筛选、目标计算和数据流保持由现有 JavaScript 实现,CSS 不改变业务口径。 目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 280 px 起步,并缩小封面预览和上下间距。例如,一个包含 6 本书、已读完 2 本的月度目标仍展示原有完成数和进度,只减少卡片占位。页面渲染、筛选、目标计算和数据流保持由现有 JavaScript 实现,CSS 不改变业务口径。
## 正式看板书架排序与分页
正式看板从 `/api/reading/shelf` 读取电子书列表。前端按当前书架状态筛选后排序,每页展示 48 本;用户切换状态或排序时回到第 1 页,点击底部页码时只替换当前书籍网格。排序框右对齐显示在页码列表上方。
排序默认按最近阅读时间倒序;还可按书名升序、书名降序或作者升序。四种方式都先将 `is_top` 置顶书籍排在前面。接口把微信读书的 `isTop` 映射为 `is_top`,前端排序只使用筛选结果的副本,避免改变其他页面共享的书架顺序。
例:筛选“全部电子书”共 101 本,选择“书名升序”并打开第 2 页。页面只显示排序后第 49 至 96 本,在网格下方显示第 1、2、3 页;切换到“最近阅读”后回到第 1 页,并按最近阅读日期倒序显示前 48 本,置顶书籍仍排在该页最前面。
## 正式看板的阅读目标持久化 ## 正式看板的阅读目标持久化
正式看板使用 `static/js/app.js`、`static/js/weapi.js` 和 `reading_api.py`。本节覆盖正式页面;前文的 Demo 存储方式不适用于正式目标。 正式看板使用 `static/js/app.js`、`static/js/weapi.js` 和 `reading_api.py`。本节覆盖正式页面;前文的 Demo 存储方式不适用于正式目标。
+28 -13
View File
@@ -1,28 +1,37 @@
# master 自动部署 # master Webhook 自动部署
GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `master` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `master`、安装依赖、执行全部数据库迁移并重启服务。手动触发时也固定部署 `master`,不能选择其他分支发布。 部署链路是:GitHub `master` push → GitHub Webhook → 服务器签名校验 → 服务器本地执行 `deploy.sh`。GitHub Actions 文件 `.github/workflows/deploy.yml` 现在只负责验证,不再通过 SSH 登录服务器。
## GitHub 配置 ## GitHub Webhook 配置
已配置的仓库 Secrets: 进入仓库 `Settings -> Webhooks -> Add webhook`,配置 Payload URL `https://laohang.space/github/webhook`、Content type `application/json`、Secret(与服务器 `GITHUB_WEBHOOK_SECRET` 完全一致),事件选择 `Just the push event` 并勾选 `Active`。只允许 `KingHWJ/WeReadBoard` 的 `master` push 触发部署。
- `SERVER_HOST` Webhook 服务配置文件 `/etc/weread-webhook.env`:
- `SERVER_PORT`
- `SERVER_SSH_KEY`
- `SERVER_USER`
可选再配置两个仓库 Variables: ```ini
GITHUB_WEBHOOK_SECRET=服务器生成的随机密钥
GITHUB_REPOSITORY=KingHWJ/WeReadBoard
DEPLOY_PROJECT_DIR=/opt/weread-board/WeReadBoard
DEPLOY_SCRIPT=/opt/weread-board/WeReadBoard/deploy.sh
WEBHOOK_HOST=127.0.0.1
WEBHOOK_PORT=9001
```
- `DEPLOY_PATH`:服务器上的项目目录,例如 `/opt/WeReadBoard` 复制 `deploy/weread-webhook.service` 到 `/etc/systemd/system/`,并执行:
- `SERVICE_NAME`:systemd 服务名,例如 `weread.service`
未配置 Variables 时,workflow 默认使用 `/opt/weread-board/WeReadBoard` 和 `weread.service`。 ```bash
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
sudo chmod 600 /etc/weread-webhook.env
sudo systemctl daemon-reload
sudo systemctl enable --now weread-webhook.service
sudo journalctl -u weread-webhook.service -f
```
## 服务器要求 ## 服务器要求
服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/master`。运行服务的环境变量仍按现有应用配置,例如 `WEREAD_API_KEY`、`WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME`。 服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/master`。运行服务的环境变量仍按现有应用配置,例如 `WEREAD_API_KEY`、`WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME`。
如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。SSH 用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。 如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。Webhook 运行用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。
`deploy.sh` 会执行: `deploy.sh` 会执行:
@@ -72,3 +81,9 @@ WantedBy=multi-user.target
登录认证还需配置 `WEREAD_SESSION_SECRET` 和首次使用的 `WEREAD_AUTH_SETUP_TOKEN`。在服务器分别运行两次 `python3 -c 'import secrets; print(secrets.token_urlsafe(48))'` 生成不同的值。迁移自动创建 `weread_auth` 表;首次访问 `/login` 时输入初始化口令、管理员账号和至少 12 位密码。初始化成功后,从 systemd 配置中删除 `WEREAD_AUTH_SETUP_TOKEN` 并重启服务。数据库中的密码只保存哈希。 登录认证还需配置 `WEREAD_SESSION_SECRET` 和首次使用的 `WEREAD_AUTH_SETUP_TOKEN`。在服务器分别运行两次 `python3 -c 'import secrets; print(secrets.token_urlsafe(48))'` 生成不同的值。迁移自动创建 `weread_auth` 表;首次访问 `/login` 时输入初始化口令、管理员账号和至少 12 位密码。初始化成功后,从 systemd 配置中删除 `WEREAD_AUTH_SETUP_TOKEN` 并重启服务。数据库中的密码只保存哈希。
`WEREAD_SESSION_COOKIE_SECURE=1` 要求浏览器通过 HTTPS 访问。仅本机 HTTP 验收可临时设为 `0`,不要在公网 HTTP 下传输登录密码。 `WEREAD_SESSION_COOKIE_SECURE=1` 要求浏览器通过 HTTPS 访问。仅本机 HTTP 验收可临时设为 `0`,不要在公网 HTTP 下传输登录密码。
## Nginx HTTPS 转发
将 `deploy/nginx-github-webhook.conf` 的 `location` 放入 `laohang.space` 的 HTTPS server 块,然后执行 `sudo nginx -t && sudo systemctl reload nginx`。Webhook 服务只监听 `127.0.0.1:9001`,不要对公网开放 9001 端口。
`.github/workflows/deploy.yml` 只执行 Python、前端和部署脚本检查。Webhook 模式不再需要 `SERVER_HOST`、`SERVER_PORT`、`SERVER_USER`、`SERVER_SSH_KEY` 这些 Actions Secrets。
+7 -17
View File
@@ -109,7 +109,7 @@ python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
应用本身未实现登录尝试限流。公网部署还应在 Nginx 或其他可信入口对 `POST /auth/login` 配置频率限制,不能信任客户端自行传入的转发 IP。 应用本身未实现登录尝试限流。公网部署还应在 Nginx 或其他可信入口对 `POST /auth/login` 配置频率限制,不能信任客户端自行传入的转发 IP。
## GitHub 自动部署 ## GitHub Webhook 自动部署
服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、按顺序执行 `migrations/*.sql`,并重启 systemd 服务。 服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、按顺序执行 `migrations/*.sql`,并重启 systemd 服务。
@@ -121,21 +121,9 @@ SERVICE_NAME=weread.service
VENV_DIR=/opt/weread-board/WeReadBoard/.venv VENV_DIR=/opt/weread-board/WeReadBoard/.venv
``` ```
GitHub Actions 文件位于 `.github/workflows/deploy.yml`,只在 `master` 分支 push 后触发,也支持手动触发;手动触发同样固定部署 `master`。仓库需要在 `Settings -> Secrets and variables -> Actions -> Secrets` 配置: GitHub Actions 文件位于 `.github/workflows/deploy.yml`,现在只负责测试和部署脚本检查。生产部署由 GitHub Webhook 触发:仓库 `Settings -> Webhooks` 的 Payload URL 配置为 `https://laohang.space/github/webhook`,Secret 与服务器 `/etc/weread-webhook.env` 中的 `GITHUB_WEBHOOK_SECRET` 一致,事件只选择 push。
| Secret | 说明 | 服务器 Webhook 服务使用 `/etc/weread-webhook.env`,至少配置 `GITHUB_WEBHOOK_SECRET`、`GITHUB_REPOSITORY=KingHWJ/WeReadBoard`、`DEPLOY_PROJECT_DIR` 和 `DEPLOY_SCRIPT`。代码内固定只允许 `master`,不接受环境变量覆盖。Nginx 将 HTTPS 请求转发至本机 `127.0.0.1:9001`;Webhook 会校验 HMAC-SHA256、仓库和分支,并在已有部署运行时拒绝重复启动。
|---|---|
| `SERVER_HOST` | 服务器 IP |
| `SERVER_PORT` | SSH 端口,未配置时按 22 处理 |
| `SERVER_USER` | SSH 登录用户 |
| `SERVER_SSH_KEY` | GitHub Actions 登录服务器用的私钥 |
可选配置放在 `Settings -> Secrets and variables -> Actions -> Variables`:
| Variable | 说明 |
|---|---|
| `DEPLOY_PATH` | 可选,默认 `/opt/weread-board/WeReadBoard` |
| `SERVICE_NAME` | 可选,默认 `weread.service` |
如果在服务器手动部署,可执行: 如果在服务器手动部署,可执行:
@@ -171,8 +159,10 @@ node --check static/js/weapi.js
本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 1280 px 桌面和 390 px 手机宽度:桌面顶栏 58 px,统计卡从约 200 px 开始,书架一行 7 本,目标页介绍区约 67 px 高,目标卡约 317 px 宽;手机顶栏 56 px,书架两列,目标卡约 298 px 宽,概览、书架和目标页均未出现横向溢出。 本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 1280 px 桌面和 390 px 手机宽度:桌面顶栏 58 px,统计卡从约 200 px 开始,书架一行 7 本,目标页介绍区约 67 px 高,目标卡约 317 px 宽;手机顶栏 56 px,书架两列,目标卡约 298 px 宽,概览、书架和目标页均未出现横向溢出。
## master 自动部署 当前书架电子书列表每页显示 48 本,页码导航位于列表下方;翻页会替换当前页内容。右侧排序框提供最近阅读、书名升序、书名降序和作者升序,置顶书籍始终优先;切换书架状态或排序会回到第 1 页。此功能使用已加载的书架数据,不需要数据库迁移。
`master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。 ## master Webhook 自动部署
`master` 分支 push 后由 GitHub Webhook 通知服务器。服务器校验签名后,在固定项目目录执行根目录 `deploy.sh`,完成拉取 `origin/master`、加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master Webhook 自动部署](deploy-master.md)。
本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。 本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
+12 -1
View File
@@ -73,7 +73,18 @@ sudo journalctl -u weread.service -n 100 --no-pager
### 检查部署分支 ### 检查部署分支
自动部署只允许 `master`。如果 Actions 没有启动,先确认提交是否已经合并并推送到 `master`;仅推送 `dev` 不会部署。服务器部署日志中的拉取目标也必须是 `origin/master`,出现其他分支时应停止发布并检查工作流与 `deploy.sh`。 自动部署只允许 `master`。先检查 GitHub Webhook 的 Recent Deliveries、服务器 `weread-webhook.service` 日志和 Nginx HTTPS 转发;仅推送 `dev` 不会部署。服务器部署日志中的拉取目标必须是 `origin/master`,出现其他分支时应停止发布并检查 Webhook 环境变量与 `deploy.sh`。
Webhook 排查命令:
```bash
sudo systemctl status weread-webhook.service --no-pager
sudo journalctl -u weread-webhook.service -n 100 --no-pager
sudo nginx -t
curl -fsS https://laohang.space/health
```
GitHub 显示签名错误时,确认 Webhook Secret 与 `/etc/weread-webhook.env` 完全一致;显示 202 但没有部署时,确认仓库全名、`refs/heads/master` 和部署锁状态。不要通过请求体传入 Shell 命令。
## 2026-09-14:接口调整后页面请求失败 ## 2026-09-14:接口调整后页面请求失败
+106 -25
View File
@@ -146,7 +146,7 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif; "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
@@ -154,14 +154,34 @@ body {
} }
/* 所有主题相关元素平滑过渡 */ /* 所有主题相关元素平滑过渡 */
.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .book-card, .navbar,
.book-cover-wrapper, .notebook-card, .note-stat, .discover-card, .discover-tab, .nav-link,
.modal-content, .modal-overlay, .footer, .db-alert-banner, .page-btn, .stat-card,
.btn-primary, .btn-secondary, .search-input, .shelf-search, .highlight-item, .chart-card,
.review-item, .detail-progress-info, .top-book-cover-wrapper, .top-book-card,
.theme-toggle, .loading-spinner { .book-card,
.book-cover-wrapper,
.notebook-card,
.note-stat,
.discover-card,
.discover-tab,
.modal-content,
.modal-overlay,
.footer,
.db-alert-banner,
.page-btn,
.btn-primary,
.btn-secondary,
.search-input,
.shelf-search,
.highlight-item,
.review-item,
.detail-progress-info,
.top-book-cover-wrapper,
.theme-toggle,
.loading-spinner {
transition: background-color 0.3s ease, border-color 0.3s ease, transition: background-color 0.3s ease, border-color 0.3s ease,
color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease; color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease;
} }
.container { .container {
@@ -264,12 +284,25 @@ body {
transform: scale(1.08); transform: scale(1.08);
} }
.theme-toggle .icon-sun { display: none; } .theme-toggle .icon-sun {
.theme-toggle .icon-moon { display: block; } display: none;
}
[data-theme="dark"] .theme-toggle .icon-sun { display: block; } .theme-toggle .icon-moon {
[data-theme="dark"] .theme-toggle .icon-moon { display: none; } display: block;
[data-theme="dark"] .theme-toggle { color: var(--toggle-icon-sun); } }
[data-theme="dark"] .theme-toggle .icon-sun {
display: block;
}
[data-theme="dark"] .theme-toggle .icon-moon {
display: none;
}
[data-theme="dark"] .theme-toggle {
color: var(--toggle-icon-sun);
}
/* ===== 顶部常驻搜索框 ===== */ /* ===== 顶部常驻搜索框 ===== */
.global-search-bar { .global-search-bar {
@@ -382,7 +415,7 @@ body {
} }
.shelf-cat-parent.active .shelf-cat-count { .shelf-cat-parent.active .shelf-cat-count {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -403,8 +436,15 @@ body {
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.shelf-cat-sub { .shelf-cat-sub {
@@ -443,7 +483,7 @@ body {
} }
.shelf-cat-sub.active .shelf-cat-count-sm { .shelf-cat-sub.active .shelf-cat-count-sm {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -617,8 +657,15 @@ body {
} }
@keyframes fadeInTab { @keyframes fadeInTab {
from { opacity: 0; transform: translateY(8px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
/* ===== 读书排行(竖向列表) ===== */ /* ===== 读书排行(竖向列表) ===== */
@@ -1090,7 +1137,7 @@ body {
color: var(--alert-text); color: var(--alert-text);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
animation: slideDown 0.3s ease-out; animation: slideDown 0.3s ease-out;
} }
@@ -1111,8 +1158,15 @@ body {
} }
@keyframes slideDown { @keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; } from {
to { transform: translateY(0); opacity: 1; } transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
/* ===== 加载动画 ===== */ /* ===== 加载动画 ===== */
@@ -1133,7 +1187,9 @@ body {
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
/* ===== 推荐发现区 ===== */ /* ===== 推荐发现区 ===== */
@@ -1756,7 +1812,7 @@ body {
} }
.thought-filter-btn.active .filter-count { .thought-filter-btn.active .filter-count {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -1964,78 +2020,100 @@ body {
.nav-links { .nav-links {
display: none; display: none;
} }
.hero-title { .hero-title {
font-size: 24px; font-size: 24px;
} }
.hero-stats { .hero-stats {
gap: 24px; gap: 24px;
} }
.hero-stat-value { .hero-stat-value {
font-size: 32px; font-size: 32px;
} }
.charts-grid { .charts-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.chart-card.large { .chart-card.large {
grid-column: auto; grid-column: auto;
} }
.book-grid { .book-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px; gap: 12px;
} }
.top-books-list { .top-books-list {
gap: 8px; gap: 8px;
} }
.top-book-row { .top-book-row {
padding: 8px 12px; padding: 8px 12px;
gap: 10px; gap: 10px;
} }
.top-book-mini-cover { .top-book-mini-cover {
width: 36px; width: 36px;
height: 48px; height: 48px;
} }
.top-book-rank-num { .top-book-rank-num {
width: 28px; width: 28px;
height: 28px; height: 28px;
font-size: 13px; font-size: 13px;
} }
.nav-update { .nav-update {
display: none; display: none;
} }
.global-search-wrapper { .global-search-wrapper {
max-width: 100%; max-width: 100%;
} }
.thoughts-toolbar { .thoughts-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.thoughts-controls { .thoughts-controls {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.thoughts-search-input { .thoughts-search-input {
width: 100%; width: 100%;
} }
.thoughts-sort-select { .thoughts-sort-select {
width: 100%; width: 100%;
} }
.shelf-search { .shelf-search {
width: 140px; width: 140px;
} }
.shelf-categories { .shelf-categories {
gap: 6px; gap: 6px;
} }
.shelf-cat-parent { .shelf-cat-parent {
font-size: 12px; font-size: 12px;
padding: 4px 10px; padding: 4px 10px;
} }
.shelf-cat-sub { .shelf-cat-sub {
font-size: 11px; font-size: 11px;
padding: 3px 9px; padding: 3px 9px;
} }
.shelf-cat-subs { .shelf-cat-subs {
padding-left: 10px; padding-left: 10px;
} }
.back-to-top { .back-to-top {
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
@@ -2043,14 +2121,17 @@ body {
height: 40px; height: 40px;
font-size: 18px; font-size: 18px;
} }
.thoughts-page-header { .thoughts-page-header {
top: 0; top: 0;
} }
.thoughts-book-cover { .thoughts-book-cover {
width: 48px; width: 48px;
height: 64px; height: 64px;
} }
.thoughts-book-title { .thoughts-book-title {
font-size: 16px; font-size: 16px;
} }
} }
+12 -6
View File
@@ -476,11 +476,13 @@ async function loadYearlyTrend() {
barWidth: "50%", barWidth: "50%",
itemStyle: { itemStyle: {
borderRadius: [8, 8, 0, 0], borderRadius: [8, 8, 0, 0],
color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [ colorStops: [
{ offset: 0, color: c.accentLight }, { offset: 0, color: c.accentLight },
{ offset: 1, color: c.accent }, { offset: 1, color: c.accent },
] }, ]
},
}, },
label: { show: true, position: "top", color: c.label, fontSize: 12, formatter: "{c}h" }, label: { show: true, position: "top", color: c.label, fontSize: 12, formatter: "{c}h" },
}], }],
@@ -538,11 +540,13 @@ async function loadTimeDistribution() {
lineStyle: { width: 2, color: c.accent }, lineStyle: { width: 2, color: c.accent },
itemStyle: { color: c.accent }, itemStyle: { color: c.accent },
areaStyle: { areaStyle: {
color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [ colorStops: [
{ offset: 0, color: c.accent + "4D" }, { offset: 0, color: c.accent + "4D" },
{ offset: 1, color: c.accent + "05" }, { offset: 1, color: c.accent + "05" },
] }, ]
},
}, },
}], }],
}); });
@@ -650,11 +654,13 @@ async function loadAuthors() {
barWidth: "50%", barWidth: "50%",
itemStyle: { itemStyle: {
borderRadius: [0, 6, 6, 0], borderRadius: [0, 6, 6, 0],
color: { type: "linear", x: 0, y: 0, x2: 1, y2: 0, color: {
type: "linear", x: 0, y: 0, x2: 1, y2: 0,
colorStops: [ colorStops: [
{ offset: 0, color: c.accentLight }, { offset: 0, color: c.accentLight },
{ offset: 1, color: c.accent }, { offset: 1, color: c.accent },
] }, ]
},
}, },
label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" }, label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" },
}], }],
+16 -7
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -8,7 +9,7 @@
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<!-- 防闪烁:在 CSS 加载后、页面渲染前设置主题 --> <!-- 防闪烁:在 CSS 加载后、页面渲染前设置主题 -->
<script> <script>
(function() { (function () {
var saved = localStorage.getItem('theme'); var saved = localStorage.getItem('theme');
if (saved) { if (saved) {
document.documentElement.setAttribute('data-theme', saved); document.documentElement.setAttribute('data-theme', saved);
@@ -18,6 +19,7 @@
})(); })();
</script> </script>
</head> </head>
<body> <body>
<!-- 数据库连接告警横幅(MySQL 连接失败时显示) --> <!-- 数据库连接告警横幅(MySQL 连接失败时显示) -->
<div id="dbAlertBanner" class="db-alert-banner" style="display:none;"> <div id="dbAlertBanner" class="db-alert-banner" style="display:none;">
@@ -56,7 +58,8 @@
<div class="container"> <div class="container">
<div class="global-search-wrapper"> <div class="global-search-wrapper">
<span class="global-search-icon">🔍</span> <span class="global-search-icon">🔍</span>
<input type="text" id="globalSearchInput" class="global-search-input" placeholder="搜索图书..." onkeydown="if(event.key==='Enter')globalSearch()"> <input type="text" id="globalSearchInput" class="global-search-input" placeholder="搜索图书..."
onkeydown="if(event.key==='Enter')globalSearch()">
<button class="global-search-btn" onclick="globalSearch()">搜索</button> <button class="global-search-btn" onclick="globalSearch()">搜索</button>
</div> </div>
</div> </div>
@@ -125,7 +128,8 @@
<label class="filter-checkbox"> <label class="filter-checkbox">
<input type="checkbox" id="finishedOnly"> 仅看已读完 <input type="checkbox" id="finishedOnly"> 仅看已读完
</label> </label>
<button class="shelf-refresh-btn" id="shelfRefreshBtn" onclick="refreshShelf()" title="重新加载书架数据"> <button class="shelf-refresh-btn" id="shelfRefreshBtn" onclick="refreshShelf()"
title="重新加载书架数据">
<span class="btn-icon">⟳</span> <span class="btn-icon">⟳</span>
<span class="btn-text">刷新</span> <span class="btn-text">刷新</span>
</button> </button>
@@ -152,7 +156,8 @@
<div class="container" style="padding-top:40px;padding-bottom:60px"> <div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">🔍 推荐发现</h2> <h2 class="section-title">🔍 推荐发现</h2>
<div class="discover-tabs"> <div class="discover-tabs">
<button class="discover-tab active" id="tabRecommend" onclick="switchDiscoverTab('recommend')">为你推荐</button> <button class="discover-tab active" id="tabRecommend"
onclick="switchDiscoverTab('recommend')">为你推荐</button>
<button class="discover-tab" id="tabSearch" onclick="switchDiscoverTab('search')">搜索书籍</button> <button class="discover-tab" id="tabSearch" onclick="switchDiscoverTab('search')">搜索书籍</button>
</div> </div>
<div id="recommendPanel" class="discover-panel"> <div id="recommendPanel" class="discover-panel">
@@ -164,7 +169,8 @@
</div> </div>
<div id="searchPanel" class="discover-panel" style="display:none"> <div id="searchPanel" class="discover-panel" style="display:none">
<div class="search-box"> <div class="search-box">
<input type="text" id="searchInput" class="search-input" placeholder="输入书名、作者或关键词..." onkeydown="if(event.key==='Enter')doSearch()"> <input type="text" id="searchInput" class="search-input" placeholder="输入书名、作者或关键词..."
onkeydown="if(event.key==='Enter')doSearch()">
<button class="btn-primary" onclick="doSearch()">搜索</button> <button class="btn-primary" onclick="doSearch()">搜索</button>
</div> </div>
<div class="discover-grid" id="searchGrid"></div> <div class="discover-grid" id="searchGrid"></div>
@@ -183,7 +189,9 @@
</div> </div>
</div> </div>
<div class="container thoughts-content" id="thoughtsContent"> <div class="container thoughts-content" id="thoughtsContent">
<div class="loading"><div class="loading-spinner"></div></div> <div class="loading">
<div class="loading-spinner"></div>
</div>
</div> </div>
</div> </div>
@@ -208,4 +216,5 @@
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
</body> </body>
</html>
</html>
+3
View File
@@ -364,6 +364,7 @@ def make_book(
author=None, author=None,
category=None, category=None,
finish=0, finish=0,
is_top=0,
read_update_time=0, read_update_time=0,
cover_url=None, cover_url=None,
deep_link=None, deep_link=None,
@@ -401,6 +402,7 @@ def make_book(
"read_time_str": duration_str(read_time) if read_time else "", "read_time_str": duration_str(read_time) if read_time else "",
"read_update_time": ts_to_date(read_update_time), "read_update_time": ts_to_date(read_update_time),
"finish_reading": finish, "finish_reading": finish,
"is_top": int(is_top or 0),
"deep_link": deep_link or "", "deep_link": deep_link or "",
} }
@@ -589,6 +591,7 @@ def shelf_books(conn, with_progress=False, progress_limit=30):
row.get("author"), row.get("author"),
row.get("category"), row.get("category"),
finish=row.get("finishReading"), finish=row.get("finishReading"),
is_top=row.get("isTop"),
read_update_time=row.get("readUpdateTime"), read_update_time=row.get("readUpdateTime"),
cover_url=row.get("cover"), cover_url=row.get("cover"),
deep_link=row.get("deepLink"), deep_link=row.get("deepLink"),
+107 -25
View File
@@ -146,7 +146,7 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif; "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
line-height: 1.6; line-height: 1.6;
@@ -154,14 +154,34 @@ body {
} }
/* 所有主题相关元素平滑过渡 */ /* 所有主题相关元素平滑过渡 */
.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .book-card, .navbar,
.book-cover-wrapper, .notebook-card, .note-stat, .discover-card, .discover-tab, .nav-link,
.modal-content, .modal-overlay, .footer, .db-alert-banner, .page-btn, .stat-card,
.btn-primary, .btn-secondary, .search-input, .shelf-search, .highlight-item, .chart-card,
.review-item, .detail-progress-info, .top-book-cover-wrapper, .top-book-card,
.theme-toggle, .loading-spinner { .book-card,
.book-cover-wrapper,
.notebook-card,
.note-stat,
.discover-card,
.discover-tab,
.modal-content,
.modal-overlay,
.footer,
.db-alert-banner,
.page-btn,
.btn-primary,
.btn-secondary,
.search-input,
.shelf-search,
.highlight-item,
.review-item,
.detail-progress-info,
.top-book-cover-wrapper,
.theme-toggle,
.loading-spinner {
transition: background-color 0.3s ease, border-color 0.3s ease, transition: background-color 0.3s ease, border-color 0.3s ease,
color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease; color 0.3s ease, box-shadow 0.3s ease, transform 0.2s ease;
} }
.container { .container {
@@ -264,12 +284,25 @@ body {
transform: scale(1.08); transform: scale(1.08);
} }
.theme-toggle .icon-sun { display: none; } .theme-toggle .icon-sun {
.theme-toggle .icon-moon { display: block; } display: none;
}
[data-theme="dark"] .theme-toggle .icon-sun { display: block; } .theme-toggle .icon-moon {
[data-theme="dark"] .theme-toggle .icon-moon { display: none; } display: block;
[data-theme="dark"] .theme-toggle { color: var(--toggle-icon-sun); } }
[data-theme="dark"] .theme-toggle .icon-sun {
display: block;
}
[data-theme="dark"] .theme-toggle .icon-moon {
display: none;
}
[data-theme="dark"] .theme-toggle {
color: var(--toggle-icon-sun);
}
/* ===== 顶部常驻搜索框 ===== */ /* ===== 顶部常驻搜索框 ===== */
.global-search-bar { .global-search-bar {
@@ -382,7 +415,7 @@ body {
} }
.shelf-cat-parent.active .shelf-cat-count { .shelf-cat-parent.active .shelf-cat-count {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -403,8 +436,15 @@ body {
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.shelf-cat-sub { .shelf-cat-sub {
@@ -443,7 +483,7 @@ body {
} }
.shelf-cat-sub.active .shelf-cat-count-sm { .shelf-cat-sub.active .shelf-cat-count-sm {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -617,8 +657,15 @@ body {
} }
@keyframes fadeInTab { @keyframes fadeInTab {
from { opacity: 0; transform: translateY(8px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
/* ===== 读书排行(竖向列表) ===== */ /* ===== 读书排行(竖向列表) ===== */
@@ -1101,7 +1148,7 @@ body {
color: var(--alert-text); color: var(--alert-text);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
animation: slideDown 0.3s ease-out; animation: slideDown 0.3s ease-out;
} }
@@ -1122,8 +1169,15 @@ body {
} }
@keyframes slideDown { @keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; } from {
to { transform: translateY(0); opacity: 1; } transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
/* ===== 加载动画 ===== */ /* ===== 加载动画 ===== */
@@ -1144,7 +1198,9 @@ body {
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
/* ===== 推荐发现区 ===== */ /* ===== 推荐发现区 ===== */
@@ -1767,7 +1823,7 @@ body {
} }
.thought-filter-btn.active .filter-count { .thought-filter-btn.active .filter-count {
background: rgba(255,255,255,0.25); background: rgba(255, 255, 255, 0.25);
color: #fff; color: #fff;
} }
@@ -1975,81 +2031,104 @@ body {
.nav-links { .nav-links {
display: none; display: none;
} }
.hero-title { .hero-title {
font-size: 24px; font-size: 24px;
} }
.hero-stats { .hero-stats {
gap: 24px; gap: 24px;
} }
.hero-stat-value { .hero-stat-value {
font-size: 32px; font-size: 32px;
} }
.charts-grid { .charts-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.chart-card.large { .chart-card.large {
grid-column: auto; grid-column: auto;
} }
.book-grid { .book-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px; gap: 12px;
} }
.top-books-list { .top-books-list {
gap: 8px; gap: 8px;
} }
.top-book-row { .top-book-row {
padding: 8px 12px; padding: 8px 12px;
gap: 10px; gap: 10px;
} }
.top-book-mini-cover { .top-book-mini-cover {
width: 36px; width: 36px;
height: 48px; height: 48px;
} }
.top-book-rank-num { .top-book-rank-num {
width: 28px; width: 28px;
height: 28px; height: 28px;
font-size: 13px; font-size: 13px;
} }
.nav-update { .nav-update {
display: none; display: none;
} }
.global-search-wrapper { .global-search-wrapper {
max-width: 100%; max-width: 100%;
} }
.thoughts-toolbar { .thoughts-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.thoughts-controls { .thoughts-controls {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.thoughts-search-input { .thoughts-search-input {
width: 100%; width: 100%;
} }
.thoughts-sort-select { .thoughts-sort-select {
width: 100%; width: 100%;
} }
.shelf-search { .shelf-search {
width: 140px; width: 140px;
} }
.shelf-filters { .shelf-filters {
gap: 10px; gap: 10px;
} }
.shelf-categories { .shelf-categories {
gap: 6px; gap: 6px;
} }
.shelf-cat-parent { .shelf-cat-parent {
font-size: 12px; font-size: 12px;
padding: 4px 10px; padding: 4px 10px;
} }
.shelf-cat-sub { .shelf-cat-sub {
font-size: 11px; font-size: 11px;
padding: 3px 9px; padding: 3px 9px;
} }
.shelf-cat-subs { .shelf-cat-subs {
padding-left: 10px; padding-left: 10px;
} }
.back-to-top { .back-to-top {
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
@@ -2057,14 +2136,17 @@ body {
height: 40px; height: 40px;
font-size: 18px; font-size: 18px;
} }
.thoughts-page-header { .thoughts-page-header {
top: 0; top: 0;
} }
.thoughts-book-cover { .thoughts-book-cover {
width: 48px; width: 48px;
height: 64px; height: 64px;
} }
.thoughts-book-title { .thoughts-book-title {
font-size: 16px; font-size: 16px;
} }
} }
+2935 -89
View File
File diff suppressed because it is too large. Load diff
+69 -12
View File
@@ -24,7 +24,7 @@ let searchComposing = false, discoverSearchTimer = null;
let recommendOffset = 0; let recommendOffset = 0;
let noteBook = 'all', noteType = 'all', goalFilter = 'all'; let noteBook = 'all', noteType = 'all', goalFilter = 'all';
let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0; let detailBook = null, bookTab = 'info', reviewFilter = 0, selectedChapter = 0;
let shelfLimit = SHELF_PAGE, chapterLimit = 30, searchScope = 'store', notesLimit = 30; let shelfPage = 1, shelfSort = 'recent', chapterLimit = 30, searchScope = 'store', notesLimit = 30;
let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = []; let editingGoalId = null, draftGoalId = null, draftIds = new Set(), draftSources = [], pendingBooks = [], pendingSources = [];
let toastTimer, storageWarning = '', goalBusy = false; let toastTimer, storageWarning = '', goalBusy = false;
@@ -332,6 +332,50 @@ function renderLists(lists) {
return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单。</div>'}</div>`; return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单。</div>'}</div>`;
} }
function compareShelfBooks(a, b) {
const pinOrder = Number(b.is_top || 0) - Number(a.is_top || 0);
if (pinOrder) return pinOrder;
const titleOrder = String(a.title || '').localeCompare(String(b.title || ''), 'zh-CN');
const authorOrder = String(a.author || '').localeCompare(String(b.author || ''), 'zh-CN');
const idOrder = String(a.id || '').localeCompare(String(b.id || ''));
if (shelfSort === 'title_asc') return titleOrder || idOrder;
if (shelfSort === 'title_desc') return -titleOrder || idOrder;
if (shelfSort === 'author_asc') return authorOrder || titleOrder || idOrder;
const dateA = String(a.read_update_time || '');
const dateB = String(b.read_update_time || '');
if (dateA !== dateB) {
if (!dateA) return 1;
if (!dateB) return -1;
return dateB.localeCompare(dateA);
}
return titleOrder || idOrder;
}
function renderShelfPagination(current, total) {
if (total <= 1) return '';
const maxButtons = 7;
let start = Math.max(1, current - 3);
let end = Math.min(total, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
let html = `<nav class="shelf-pagination" aria-label="书架分页">`;
html += `<button type="button" class="page-btn" ${current <= 1 ? 'disabled' : ''} data-action="shelf-page" data-id="${current - 1}">上一页</button>`;
if (start > 1) {
html += `<button type="button" class="page-btn" data-action="shelf-page" data-id="1">1</button>`;
if (start > 2) html += '<span class="page-info" aria-hidden="true">…</span>';
}
for (let i = start; i <= end; i++) {
html += `<button type="button" class="page-btn ${i === current ? 'active' : ''}" ${i === current ? 'aria-current="page"' : ''} data-action="shelf-page" data-id="${i}">${i}</button>`;
}
if (end < total) {
if (end < total - 1) html += '<span class="page-info" aria-hidden="true">…</span>';
html += `<button type="button" class="page-btn" data-action="shelf-page" data-id="${total}">${total}</button>`;
}
html += `<button type="button" class="page-btn" ${current >= total ? 'disabled' : ''} data-action="shelf-page" data-id="${current + 1}">下一页</button></nav>`;
return html;
}
function renderShelf() { function renderShelf() {
if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf'); if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf');
if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); } if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); }
@@ -348,8 +392,20 @@ function renderShelf() {
} else if (shelfTab === 'articles') { } else if (shelfTab === 'articles') {
content = `<article class="card media-card"><div class="media-icon">▤</div><div><h3>${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}</h3><p>这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。</p><p class="hint">书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。</p><button class="text-button" data-action="article-search">去发现页搜索 →</button></div></article>`; content = `<article class="card media-card"><div class="media-icon">▤</div><div><h3>${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}</h3><p>这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。</p><p class="hint">书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。</p><button class="text-button" data-action="article-search">去发现页搜索 →</button></div></article>`;
} else { } else {
const shown = filtered.slice(0, shelfLimit); const sorted = [...filtered].sort(compareShelfBooks);
content = `<div class="card panel"><div class="books full-books">${shown.map(bookCard).join('') || '<div class="empty">这里还没有对应的书。</div>'}</div>${filtered.length > shown.length ? `<button class="text-button section-space" data-action="shelf-more">+ 继续加载(还有 ${filtered.length - shown.length} 本)</button>` : ''}</div>`; const totalPages = Math.ceil(sorted.length / SHELF_PAGE);
const currentPage = totalPages ? Math.min(shelfPage, totalPages) : 1;
shelfPage = currentPage;
const shown = sorted.slice((currentPage - 1) * SHELF_PAGE, currentPage * SHELF_PAGE);
const pageSummary = totalPages ? `共 ${sorted.length} 本 · 第 ${currentPage}/${totalPages} 页` : `共 ${sorted.length} 本`;
const sortOptions = [
['recent', '最近阅读'],
['title_asc', '书名升序'],
['title_desc', '书名降序'],
['author_asc', '作者升序'],
];
const sortControl = sortOptions.map(([value, label]) => `<option value="${value}" ${shelfSort === value ? 'selected' : ''}>${label}</option>`).join('');
content = `<div class="shelf-toolbar"><span class="hint">${pageSummary}</span><label class="shelf-sort-control" for="shelf-sort">排序 <select id="shelf-sort" aria-label="书架排序">${sortControl}</select></label></div><div class="card panel"><div class="books full-books">${shown.map(bookCard).join('') || '<div class="empty">这里还没有对应的书。</div>'}</div></div>${renderShelfPagination(currentPage, totalPages)}`;
} }
const sub = `${counts.ebooks || 0} 本电子书 · ${counts.albums || 0} 个有声专辑 · ${counts.mp ? 1 : 0} 个文章收藏入口,共 ${counts.total || 0} 个书架条目。`; const sub = `${counts.ebooks || 0} 本电子书 · ${counts.albums || 0} 个有声专辑 · ${counts.mp ? 1 : 0} 个文章收藏入口,共 ${counts.total || 0} 个书架条目。`;
@@ -531,12 +587,12 @@ function renderNotes() {
? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes') ? statusBox('error', '暂时无法读取笔记', loadError.notes[noteBook] || '', 'retry-notes')
: statusBox('loading', '正在读取笔记内容…', '划线、想法与书签数量。')) : statusBox('loading', '正在读取笔记内容…', '划线、想法与书签数量。'))
: `<div class="metrics-row"><span><strong>${counts.highlights + counts.thoughts + counts.bookmarks}</strong>条笔记</span><span>划线 ${counts.highlights}</span><span>想法 ${counts.thoughts}</span><span>书签 ${counts.bookmarks}</span></div>` + : `<div class="metrics-row"><span><strong>${counts.highlights + counts.thoughts + counts.bookmarks}</strong>条笔记</span><span>划线 ${counts.highlights}</span><span>想法 ${counts.thoughts}</span><span>书签 ${counts.bookmarks}</span></div>` +
tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') + tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') +
(noteType === 'bookmark' (noteType === 'bookmark'
? `<div class="card empty">${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。</div>` ? `<div class="card empty">${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。</div>`
: (noteSlice.map(noteCard).join('') || '<div class="card empty">当前筛选没有笔记。</div>')) + : (noteSlice.map(noteCard).join('') || '<div class="card empty">当前筛选没有笔记。</div>')) +
(noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `<button class="text-button section-space" data-action="notes-more">+ 继续加载(还有 ${shownNotes.length - noteSlice.length} 条)</button>` : '') + (noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `<button class="text-button section-space" data-action="notes-more">+ 继续加载(还有 ${shownNotes.length - noteSlice.length} 条)</button>` : '') +
`<p class="hint section-space">${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。</p>`; `<p class="hint section-space">${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。</p>`;
return welcome('把读过的,留在心里', `${notebooksData.totals.books} 本书留下过笔记,共 ${notebooksData.totals.notes} 条。`, '<button class="secondary" data-action="export-notes">↓ 导出当前书目笔记</button>') + return welcome('把读过的,留在心里', `${notebooksData.totals.books} 本书留下过笔记,共 ${notebooksData.totals.notes} 条。`, '<button class="secondary" data-action="export-notes">↓ 导出当前书目笔记</button>') +
`<div class="notebook-grid"><aside class="card panel notebook-menu"><button data-action="notebook" data-id="all" class="${noteBook === 'all' ? 'active' : ''}"><b>全部笔记本</b><small>${notebooksData.totals.books} 本书 · ${notebooksData.totals.notes} 条笔记</small></button>${menuBooks.map(item => `<button data-action="notebook" data-id="${esc(item.id)}" class="${noteBook === item.id ? 'active' : ''}"><b>${esc(item.title)}</b><small>${item.total_notes} 条 · 含书签 ${item.bookmark_count}</small></button>`).join('')}</aside><div>${body}</div></div>`; `<div class="notebook-grid"><aside class="card panel notebook-menu"><button data-action="notebook" data-id="all" class="${noteBook === 'all' ? 'active' : ''}"><b>全部笔记本</b><small>${notebooksData.totals.books} 本书 · ${notebooksData.totals.notes} 条笔记</small></button>${menuBooks.map(item => `<button data-action="notebook" data-id="${esc(item.id)}" class="${noteBook === item.id ? 'active' : ''}"><b>${esc(item.title)}</b><small>${item.total_notes} 条 · 含书签 ${item.bookmark_count}</small></button>`).join('')}</aside><div>${body}</div></div>`;
@@ -600,7 +656,7 @@ function renderDiscover() {
} else { } else {
const list = payload.books || []; const list = payload.books || [];
const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase())); const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase()));
content = `<div class="card panel"><div class="books full-books">${(payload.local ? local.slice(0, shelfLimit) : list).map(searchCard).join('') || '<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`; content = `<div class="card panel"><div class="books full-books">${(payload.local ? local.slice(0, SHELF_PAGE) : list).map(searchCard).join('') || '<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`;
} }
return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') + return welcome('下一本,遇见什么?', '寻找一本书,也发现一份恰好适合你的书单。') +
@@ -709,8 +765,8 @@ function handleAction(button) {
case 'add-list': { const list = booklists.find(l => l.id === id); if (list) chooseTarget(list.ids, [list.name]); break; } case 'add-list': { const list = booklists.find(l => l.id === id); if (list) chooseTarget(list.ids, [list.name]); break; }
case 'append-goal': appendGoal(id); break; case 'append-goal': appendGoal(id); break;
case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break; case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break;
case 'shelf-tab': shelfTab = id; shelfLimit = SHELF_PAGE; renderPage(); break; case 'shelf-tab': shelfTab = id; shelfPage = 1; renderPage(); break;
case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break; case 'shelf-page': shelfPage = Number(id); renderPage(); break;
case 'period': period = id; renderPage(); break; case 'period': period = id; renderPage(); break;
case 'discover-tab': discoverTab = id; renderPage(); break; case 'discover-tab': discoverTab = id; renderPage(); break;
case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); break; case 'refresh-recommend': recommendOffset += 12; ensureDiscover(true, true); break;
@@ -758,6 +814,7 @@ document.addEventListener('change', event => {
selectedChapter = event.target.value; bookTab = 'popular'; renderBookDetail(); selectedChapter = event.target.value; bookTab = 'popular'; renderBookDetail();
if (!numberData[`hl:${detailBook}:${selectedChapter}`]) WeApi.highlights(detailBook, selectedChapter).then(res => { numberData[`hl:${detailBook}:${selectedChapter}`] = res; renderBookDetail(); }).catch(error => toast(error.message)); if (!numberData[`hl:${detailBook}:${selectedChapter}`]) WeApi.highlights(detailBook, selectedChapter).then(res => { numberData[`hl:${detailBook}:${selectedChapter}`] = res; renderBookDetail(); }).catch(error => toast(error.message));
} }
if (event.target.id === 'shelf-sort') { shelfSort = event.target.value; shelfPage = 1; renderPage(); }
if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); } if (event.target.id === 'search-scope') { searchScope = event.target.value; renderPage(); }
}); });
$('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; }; $('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; };
+15 -15
View File
@@ -1,25 +1,25 @@
// 所有数据均为设计示例,不来自真实账号。 // 所有数据均为设计示例,不来自真实账号。
const books = [{title:'也许你该找个人聊聊',short:'也许你该\n找个人\n聊聊',author:'洛莉·戈特利布',en:'MAYBE YOU SHOULD TALK TO SOMEONE',cover:'c1',progress:68,status:'reading',score:'9.1',desc:'一位心理治疗师的回忆录。跟随几个普通人的故事,理解关系、失去与成长,也重新认识自己。'},{title:'三体',short:'三体',author:'刘慈欣',en:'THE THREE-BODY PROBLEM',cover:'c2',progress:42,status:'reading',score:'9.4',desc:'从一段隐秘的往事出发,走进人类与宇宙文明的宏大故事。科学、想象与命运交织,打开另一种观看世界的尺度。'},{title:'蛤蟆先生去看心理医生',short:'蛤蟆先生\n去看\n心理医生',author:'罗伯特·戴博德',en:'COUNSELLING FOR TOADS',cover:'c3',progress:85,status:'reading',score:'8.8',desc:'借由蛤蟆先生与心理咨询师的对话,探索情绪与行为背后的原因。这是一次关于理解自己、为自己负责的旅程。'},{title:'人生海海',short:'人生\n海海',author:'麦家',en:'LIFE IS A VAST SEA',cover:'c4',progress:26,status:'reading',score:'9.0',desc:'在复杂的时代与人情之中,讲述一个人的秘密与命运。人生如海,有暗流,也有让人继续前行的力量。'},{title:'活着',short:'活着',author:'余华',en:'TO LIVE',cover:'c3',progress:100,status:'done',score:'9.5',desc:'一个人与时代相遇的故事,关于生命的韧性与日常的重量。'},{title:'被讨厌的勇气',short:'被讨厌\n的勇气',author:'岸见一郎 / 古贺史健',en:'THE COURAGE TO BE DISLIKED',cover:'c1',progress:100,status:'done',score:'8.6',desc:'以对话的方式讨论人际关系、自我接纳与选择的自由。'},{title:'悉达多',short:'悉达多',author:'赫尔曼·黑塞',en:'SIDDHARTHA',cover:'c4',progress:0,status:'wish',score:'9.2',desc:'一个寻找自我的人,穿过不同的人生经验,在河流边学会倾听。'},{title:'置身事内',short:'置身\n事内',author:'兰小欢',en:'INSIDE THE SYSTEM',cover:'c2',progress:0,status:'wish',score:'9.3',desc:'从具体的经济生活出发,理解政府与经济发展的关系。'}]; const books = [{ title: '也许你该找个人聊聊', short: '也许你该\n找个人\n聊聊', author: '洛莉·戈特利布', en: 'MAYBE YOU SHOULD TALK TO SOMEONE', cover: 'c1', progress: 68, status: 'reading', score: '9.1', desc: '一位心理治疗师的回忆录。跟随几个普通人的故事,理解关系、失去与成长,也重新认识自己。' }, { title: '三体', short: '三体', author: '刘慈欣', en: 'THE THREE-BODY PROBLEM', cover: 'c2', progress: 42, status: 'reading', score: '9.4', desc: '从一段隐秘的往事出发,走进人类与宇宙文明的宏大故事。科学、想象与命运交织,打开另一种观看世界的尺度。' }, { title: '蛤蟆先生去看心理医生', short: '蛤蟆先生\n去看\n心理医生', author: '罗伯特·戴博德', en: 'COUNSELLING FOR TOADS', cover: 'c3', progress: 85, status: 'reading', score: '8.8', desc: '借由蛤蟆先生与心理咨询师的对话,探索情绪与行为背后的原因。这是一次关于理解自己、为自己负责的旅程。' }, { title: '人生海海', short: '人生\n海海', author: '麦家', en: 'LIFE IS A VAST SEA', cover: 'c4', progress: 26, status: 'reading', score: '9.0', desc: '在复杂的时代与人情之中,讲述一个人的秘密与命运。人生如海,有暗流,也有让人继续前行的力量。' }, { title: '活着', short: '活着', author: '余华', en: 'TO LIVE', cover: 'c3', progress: 100, status: 'done', score: '9.5', desc: '一个人与时代相遇的故事,关于生命的韧性与日常的重量。' }, { title: '被讨厌的勇气', short: '被讨厌\n的勇气', author: '岸见一郎 / 古贺史健', en: 'THE COURAGE TO BE DISLIKED', cover: 'c1', progress: 100, status: 'done', score: '8.6', desc: '以对话的方式讨论人际关系、自我接纳与选择的自由。' }, { title: '悉达多', short: '悉达多', author: '赫尔曼·黑塞', en: 'SIDDHARTHA', cover: 'c4', progress: 0, status: 'wish', score: '9.2', desc: '一个寻找自我的人,穿过不同的人生经验,在河流边学会倾听。' }, { title: '置身事内', short: '置身\n事内', author: '兰小欢', en: 'INSIDE THE SYSTEM', cover: 'c2', progress: 0, status: 'wish', score: '9.3', desc: '从具体的经济生活出发,理解政府与经济发展的关系。' }];
books.forEach((book, index) => { book.id = `b${index}`; }); books.forEach((book, index) => { book.id = `b${index}`; });
const booklists = [ const booklists = [
{id:'l1',name:'在故事里,读懂人生',source:'我的书单',description:'从别人的故事里,找到自己生活的回声。',ids:['b3','b4','b6']}, { id: 'l1', name: '在故事里,读懂人生', source: '我的书单', description: '从别人的故事里,找到自己生活的回声。', ids: ['b3', 'b4', 'b6'] },
{id:'l2',name:'心理学,向内走一程',source:'我的书单',description:'理解情绪与关系,也练习温柔地对待自己。',ids:['b0','b2','b5']}, { id: 'l2', name: '心理学,向内走一程', source: '我的书单', description: '理解情绪与关系,也练习温柔地对待自己。', ids: ['b0', 'b2', 'b5'] },
{id:'l3',name:'重新认识这个世界',source:'公开书单 · 示例',description:'从宇宙到日常,拓宽理解世界的尺度。',ids:['b1','b7','b6']}, { id: 'l3', name: '重新认识这个世界', source: '公开书单 · 示例', description: '从宇宙到日常,拓宽理解世界的尺度。', ids: ['b1', 'b7', 'b6'] },
{id:'l4',name:'适合慢慢读的夜晚',source:'公开书单 · 示例',description:'把时间留给文学,让心绪落在书页之间。',ids:['b4','b6','b0']} { id: 'l4', name: '适合慢慢读的夜晚', source: '公开书单 · 示例', description: '把时间留给文学,让心绪落在书页之间。', ids: ['b4', 'b6', 'b0'] }
]; ];
const initialGoals = [ const initialGoals = [
{id:'g1',name:'秋季文学计划',start:'2026-09-01',end:'2026-11-30',ids:['b3','b4','b6','b1'],sources:['在故事里,读懂人生']}, { id: 'g1', name: '秋季文学计划', start: '2026-09-01', end: '2026-11-30', ids: ['b3', 'b4', 'b6', 'b1'], sources: ['在故事里,读懂人生'] },
{id:'g2',name:'给内心一点生长的空间',start:'2026-09-01',end:'2026-10-31',ids:['b0','b2','b5'],sources:['心理学,向内走一程']}, { id: 'g2', name: '给内心一点生长的空间', start: '2026-09-01', end: '2026-10-31', ids: ['b0', 'b2', 'b5'], sources: ['心理学,向内走一程'] },
{id:'g3',name:'读完的书,成为自己的底气',start:'2026-08-01',end:'2026-08-31',ids:['b4','b5'],sources:[]} { id: 'g3', name: '读完的书,成为自己的底气', start: '2026-08-01', end: '2026-08-31', ids: ['b4', 'b5'], sources: [] }
]; ];
const personalNotes = [ const personalNotes = [
{id:'n1',bookId:'b0',type:'thought',text:'把注意力还给生活本身,让平凡的日子有迹可循。',date:'2026-09-11'}, { id: 'n1', bookId: 'b0', type: 'thought', text: '把注意力还给生活本身,让平凡的日子有迹可循。', date: '2026-09-11' },
{id:'n2',bookId:'b1',type:'highlight',text:'在辽阔的未知面前,每一个问题都值得被认真对待。',date:'2026-09-10'}, { id: 'n2', bookId: 'b1', type: 'highlight', text: '在辽阔的未知面前,每一个问题都值得被认真对待。', date: '2026-09-10' },
{id:'n3',bookId:'b2',type:'thought',text:'允许情绪出现,也给自己一些理解它的时间。',date:'2026-09-08'}, { id: 'n3', bookId: 'b2', type: 'thought', text: '允许情绪出现,也给自己一些理解它的时间。', date: '2026-09-08' },
{id:'n4',bookId:'b3',type:'highlight',text:'人生的答案,或许藏在继续向前的日子里。',date:'2026-09-06'} { id: 'n4', bookId: 'b3', type: 'highlight', text: '人生的答案,或许藏在继续向前的日子里。', date: '2026-09-06' }
]; ];
const publicReviews = [ const publicReviews = [
{id:'r1',name:'山间来信',rating:5,type:'推荐',text:'适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。',date:'2026-09-10'}, { id: 'r1', name: '山间来信', rating: 5, type: '推荐', text: '适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。', date: '2026-09-10' },
{id:'r2',name:'晚风书屋',rating:3,type:'一般',text:'有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。',date:'2026-09-09'}, { id: 'r2', name: '晚风书屋', rating: 3, type: '一般', text: '有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。', date: '2026-09-09' },
{id:'r3',name:'一页一页',rating:1,type:'不行',text:'这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。',date:'2026-09-08'} { id: 'r3', name: '一页一页', rating: 1, type: '不行', text: '这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。', date: '2026-09-08' }
]; ];
+160 -15
View File
@@ -1,20 +1,165 @@
<!doctype html> <!doctype html>
<html lang="zh-CN" data-theme="light"> <html lang="zh-CN" data-theme="light">
<head> <head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"> <meta charset="UTF-8">
<title>阅间 · 微信读书看板</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/css/styles.css"> <meta name="csrf-token" content="{{ csrf_token() }}">
<style> <title>阅间 · 微信读书看板</title>
.footer{flex-wrap:wrap;gap:12px}.footer-meta{display:flex;align-items:center;gap:12px}.icp-link{color:inherit;text-decoration:none}.icp-link:hover{color:var(--accent)} <link rel="stylesheet" href="/static/css/styles.css">
@media(max-width:600px){.footer-state{display:none}} <style>
</style> .footer {
flex-wrap: wrap;
gap: 12px
}
.footer-meta {
display: flex;
align-items: center;
gap: 12px
}
.icp-link {
color: inherit;
text-decoration: none
}
.icp-link:hover {
color: var(--accent)
}
@media(max-width:600px) {
.footer-state {
display: none
}
}
</style>
</head> </head>
<body> <body>
<svg style="display:none" aria-hidden="true"><defs> <svg style="display:none" aria-hidden="true">
<symbol id="book" viewBox="0 0 24 24"><path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/></symbol><symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></symbol><symbol id="note" viewBox="0 0 24 24"><path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z"/></symbol><symbol id="search-icon" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></symbol><symbol id="chart" viewBox="0 0 24 24"><path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4"/></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="sync" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6"/></symbol><symbol id="flag" viewBox="0 0 24 24"><path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0"/></symbol> <defs>
</defs></svg> <symbol id="book" viewBox="0 0 24 24">
<aside class="sidebar"><div class="logo"><div class="logo-mark"><svg><use href="#book"/></svg></div><div>阅间<small>READING SPACE</small></div></div><div class="navlabel">我的阅读空间</div><nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg><use href="#grid"/></svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg><use href="#book"/></svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg><use href="#flag"/></svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg><use href="#note"/></svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg><use href="#search-icon"/></svg><span>发现好书</span></button></nav><div class="sidebar-bottom"><div class="connection"><span class="dot"></span>微信读书 · 个人空间<small>让每一页,都留下痕迹</small></div><div class="profile"><div class="avatar">读</div><div>我的阅读空间<small>数据来自你的微信读书账号</small></div></div></div></aside> <path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15" />
<main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div><div class="top-actions"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘ K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg><use href="#moon"/></svg></button><div class="avatar">L</div><form method="post" action="{{ url_for('auth.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout-button" type="submit">退出</button></form></div></header><div class="demo-banner"><span><span class="dot"></span><span id="conn-state">正在连接微信读书…</span></span><span>阅读目标跨浏览器保存</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><div class="footer-meta"><span class="footer-state" id="footer-state">数据来自微信读书个人账号</span><a class="icp-link" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">浙ICP备202607916号-1</a></div></footer></main> </symbol>
<dialog id="detail" aria-label="内容详情"><div class="dialog-top"><h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail" aria-label="关闭详情">✕</button></div><div id="detail-content"></div></dialog> <symbol id="grid" viewBox="0 0 24 24">
<dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>保存后可在其他浏览器查看</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog> <rect x="3" y="3" width="7" height="7" rx="1.5" />
<div id="toast" class="hidden" role="status"></div><script src="/static/js/weapi.js"></script><script src="/static/js/app.js"></script></body></html> <rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</symbol>
<symbol id="note" viewBox="0 0 24 24">
<path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z" />
</symbol>
<symbol id="search-icon" viewBox="0 0 24 24">
<circle cx="10" cy="10" r="6.5" />
<path d="m15 15 6 6" />
</symbol>
<symbol id="chart" viewBox="0 0 24 24">
<path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4" />
</symbol>
<symbol id="clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 6v6l4 2" />
</symbol>
<symbol id="moon" viewBox="0 0 24 24">
<path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z" />
</symbol>
<symbol id="sun" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1" />
</symbol>
<symbol id="sync" viewBox="0 0 24 24">
<path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6" />
</symbol>
<symbol id="flag" viewBox="0 0 24 24">
<path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0" />
</symbol>
</defs>
</svg>
<aside class="sidebar">
<div class="logo">
<div class="logo-mark"><svg>
<use href="#book" />
</svg></div>
<div>阅间<small>READING SPACE</small></div>
</div>
<div class="navlabel">我的阅读空间</div>
<nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg>
<use href="#grid" />
</svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg>
<use href="#book" />
</svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg>
<use href="#flag" />
</svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg>
<use href="#note" />
</svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg>
<use href="#search-icon" />
</svg><span>发现好书</span></button></nav>
<div class="sidebar-bottom">
<div class="connection"><span class="dot"></span>微信读书 · 个人空间<small>让每一页,都留下痕迹</small></div>
<div class="profile">
<div class="avatar">读</div>
<div>我的阅读空间<small>数据来自你的微信读书账号</small></div>
</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div>
<div class="top-actions"><label class="searchbox"><svg>
<use href="#search-icon" />
</svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘
K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg>
<use href="#moon" />
</svg></button>
<div class="avatar">L</div>
<form method="post" action="{{ url_for('auth.logout') }}"><input type="hidden" name="csrf_token"
value="{{ csrf_token() }}"><button class="text-button logout-button" type="submit">退出</button>
</form>
</div>
</header>
<div class="demo-banner"><span><span class="dot"></span><span
id="conn-state">正在连接微信读书…</span></span><span>阅读目标跨浏览器保存</span></div>
<div id="page-content"></div>
<footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span>
<div class="footer-meta"><span class="footer-state" id="footer-state">数据来自微信读书个人账号</span><a class="icp-link"
href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">浙ICP备202607916号-1</a>
</div>
</footer>
</main>
<dialog id="detail" aria-label="内容详情">
<div class="dialog-top">
<h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail"
aria-label="关闭详情">✕</button>
</div>
<div id="detail-content"></div>
</dialog>
<dialog id="goal-editor" aria-label="编辑阅读目标">
<form id="goal-form">
<div class="dialog-top">
<div>
<div class="eyebrow">MAKE ROOM FOR READING</div>
<h2 id="editor-title">新建阅读目标</h2>
</div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button>
</div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划"
required></label>
<div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label
class="field">结束日期<input id="goal-end" type="date" required></label></div>
<div class="section-head picker-head">
<h3>想读些什么?</h3><span id="selection-count" class="pill"></span>
</div>
<p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search"
placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label>
<div id="goal-picker"></div>
<p id="goal-error" class="form-error" role="alert"></p>
<div class="editor-footer"><span>保存后可在其他浏览器查看</span><button class="primary" type="submit">保存阅读目标</button>
</div>
</form>
</dialog>
<div id="toast" class="hidden" role="status"></div>
<script src="/static/js/weapi.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+43 -33
View File
@@ -1,39 +1,49 @@
<!doctype html> <!doctype html>
<html lang="zh-CN" data-theme="light"> <html lang="zh-CN" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>登录 · 阅间</title> <title>登录 · 阅间</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head> </head>
<body class="login-page"> <body class="login-page">
<main class="login-shell"> <main class="login-shell">
<div class="logo login-logo"><div class="logo-mark"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4c4-1 6 0 8 2 2-2 4-3 8-2v15c-4-1-6 0-8 2-2-2-4-3-8-2zM12 6v15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></div><div>阅间<small>READING SPACE</small></div></div> <div class="logo login-logo">
<section class="login-panel card"> <div class="logo-mark"><svg viewBox="0 0 24 24" aria-hidden="true">
<div class="eyebrow">YOUR READING SPACE</div> <path d="M4 4c4-1 6 0 8 2 2-2 4-3 8-2v15c-4-1-6 0-8 2-2-2-4-3-8-2zM12 6v15" fill="none" stroke="currentColor"
<h1>{{ '服务暂不可用' if unavailable else ('首次设置' if not initialized else '欢迎回来') }}</h1> stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
<p>{{ '请检查服务配置或数据库连接。' if unavailable else ('创建管理员账号以启用访问保护。' if not initialized else '登录后继续你的阅读旅程。') }}</p> </svg></div>
{% if error %}<div class="login-error" role="alert">{{ error }}</div>{% endif %} <div>阅间<small>READING SPACE</small></div>
{% if not unavailable and not initialized %} </div>
{% if not setup_enabled %}<div class="login-error" role="alert">尚未配置一次性初始化口令,请联系管理员。</div>{% endif %} <section class="login-panel card">
<form method="post" action="{{ url_for('auth.initialize_account') }}" class="login-form"> <div class="eyebrow">YOUR READING SPACE</div>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <h1>{{ '服务暂不可用' if unavailable else ('首次设置' if not initialized else '欢迎回来') }}</h1>
<label>一次性初始化口令<input name="setup_token" type="password" autocomplete="off" required autofocus></label> <p>{{ '请检查服务配置或数据库连接。' if unavailable else ('创建管理员账号以启用访问保护。' if not initialized else '登录后继续你的阅读旅程。') }}</p>
<label>管理员账号<input name="username" maxlength="64" autocomplete="username" required></label> {% if error %}<div class="login-error" role="alert">{{ error }}</div>{% endif %}
<label>设置密码<input name="password" type="password" minlength="12" maxlength="256" autocomplete="new-password" required></label> {% if not unavailable and not initialized %}
<button class="primary" type="submit" {% if not setup_enabled %}disabled{% endif %}>创建账号并进入</button> {% if not setup_enabled %}<div class="login-error" role="alert">尚未配置一次性初始化口令,请联系管理员。</div>{% endif %}
</form> <form method="post" action="{{ url_for('auth.initialize_account') }}" class="login-form">
{% elif not unavailable %} <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<form method="post" action="{{ url_for('auth.login') }}" class="login-form"> <label>一次性初始化口令<input name="setup_token" type="password" autocomplete="off" required autofocus></label>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <label>管理员账号<input name="username" maxlength="64" autocomplete="username" required></label>
<label>账号<input name="username" maxlength="64" autocomplete="username" required autofocus></label> <label>设置密码<input name="password" type="password" minlength="12" maxlength="256" autocomplete="new-password"
<label>密码<input name="password" type="password" autocomplete="current-password" required></label> required></label>
<button class="primary" type="submit">登录</button> <button class="primary" type="submit" {% if not setup_enabled %}disabled{% endif %}>创建账号并进入</button>
</form> </form>
{% endif %} {% elif not unavailable %}
</section> <form method="post" action="{{ url_for('auth.login') }}" class="login-form">
<p class="login-foot">阅间 · 与好书相伴,与自己相遇</p> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</main> <label>账号<input name="username" maxlength="64" autocomplete="username" required autofocus></label>
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
<button class="primary" type="submit">登录</button>
</form>
{% endif %}
</section>
<p class="login-foot">阅间 · 与好书相伴,与自己相遇</p>
</main>
</body> </body>
</html>
</html>
+6 -6
View File
@@ -8,16 +8,16 @@ if ! grep -q '^ - master$' "$WORKFLOW"; then
echo "部署工作流尚未监听 master" echo "部署工作流尚未监听 master"
exit 1 exit 1
fi fi
if grep -q '^ - dev$' "$WORKFLOW"; then if ! grep -q 'deploy_webhook.py' "$ROOT_DIR/deploy/weread-webhook.service"; then
echo "部署工作流仍监听 dev" echo "Webhook systemd 服务未配置"
exit 1 exit 1
fi fi
if ! grep -q '^ group: deploy-master$' "$WORKFLOW"; then if ! grep -q 'DEFAULT_BRANCH = "master"' "$ROOT_DIR/deploy_webhook.py"; then
echo "部署并发组尚未切换到 master" echo "Webhook 未固定 master 分支"
exit 1 exit 1
fi fi
if ! grep -q '^ DEPLOY_BRANCH: master$' "$WORKFLOW"; then if grep -q 'SERVER_SSH_KEY\|ssh -i' "$WORKFLOW"; then
echo "手动部署分支尚未固定为 master" echo "验证工作流仍包含服务器 SSH 部署"
exit 1 exit 1
fi fi
+74
View File
@@ -0,0 +1,74 @@
import hashlib
import hmac
import json
import unittest
from unittest.mock import patch
from deploy_webhook import create_app
class WebhookTest(unittest.TestCase):
def setUp(self):
self.process = None
self.app = create_app({
"TESTING": True,
"WEBHOOK_SECRET": "webhook-secret",
"REPOSITORY": "KingHWJ/WeReadBoard",
"BRANCH": "master",
"PROJECT_DIR": "/srv/weread",
"DEPLOY_SCRIPT": "deploy.sh",
})
self.client = self.app.test_client()
@staticmethod
def signed(payload):
body = json.dumps(payload, separators=(",", ":")).encode()
signature = hmac.new(b"webhook-secret", body, hashlib.sha256).hexdigest()
return body, {
"Content-Type": "application/json",
"X-GitHub-Event": "push",
"X-Hub-Signature-256": f"sha256={signature}",
}
def test_health_is_available(self):
self.assertEqual(self.client.get("/health").status_code, 200)
def test_invalid_signature_is_rejected(self):
response = self.client.post(
"/github/webhook",
data=b"{}",
headers={"X-GitHub-Event": "push", "X-Hub-Signature-256": "sha256=bad"},
)
self.assertEqual(response.status_code, 401)
@patch("deploy_webhook.subprocess.Popen")
def test_only_matching_master_push_starts_deployment(self, popen):
payload = {"ref": "refs/heads/master", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
body, headers = self.signed(payload)
response = self.client.post("/github/webhook", data=body, headers=headers)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.json["status"], "accepted")
popen.assert_called_once()
self.assertEqual(popen.call_args.args[0], ["/srv/weread/deploy.sh"])
@patch("deploy_webhook.subprocess.Popen")
def test_dev_push_does_not_start_deployment(self, popen):
payload = {"ref": "refs/heads/dev", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
body, headers = self.signed(payload)
response = self.client.post("/github/webhook", data=body, headers=headers)
self.assertEqual(response.status_code, 202)
popen.assert_not_called()
@patch("deploy_webhook.subprocess.Popen")
def test_duplicate_push_is_not_started_while_deploying(self, popen):
popen.return_value.poll.return_value = None
payload = {"ref": "refs/heads/master", "repository": {"full_name": "KingHWJ/WeReadBoard"}}
body, headers = self.signed(payload)
self.assertEqual(self.client.post("/github/webhook", data=body, headers=headers).json["status"], "accepted")
response = self.client.post("/github/webhook", data=body, headers=headers)
self.assertEqual(response.json["status"], "already_deploying")
popen.assert_called_once()
if __name__ == "__main__":
unittest.main()