diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3aaad50..9a84e20 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,48 +1,32 @@ -name: Deploy WeReadBoard +name: Verify WeReadBoard on: push: branches: - master + - dev + pull_request: + branches: + - master + - dev workflow_dispatch: jobs: - deploy: + verify: runs-on: ubuntu-latest - concurrency: - group: deploy-master - cancel-in-progress: true steps: - - name: Prepare SSH - run: | - mkdir -p ~/.ssh - printf '%s\n' "${{ secrets.SERVER_SSH_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - - - name: Deploy - env: - SERVER_HOST: ${{ secrets.SERVER_HOST }} - SERVER_PORT: ${{ secrets.SERVER_PORT }} - SERVER_USER: ${{ secrets.SERVER_USER }} - DEPLOY_PATH: ${{ vars.DEPLOY_PATH }} - SERVICE_NAME: ${{ vars.SERVICE_NAME }} - DEPLOY_BRANCH: master - 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" + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: python -m pip install -r requirements.txt + - name: Run Python tests + run: python -m unittest discover -s tests -v + - name: Run frontend tests + run: node --test tests/goals_frontend.test.cjs tests/weapi_fallback.test.cjs + - name: Check deployment scripts + run: bash tests/deploy_master_only.test.sh && bash -n deploy.sh diff --git a/README.md b/README.md index 6c42184..21f1208 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,13 @@ PORT=8080 ./start_dashboard.sh # 或指定端口 ## 自动部署 -服务器部署使用根目录的 `deploy.sh`: +服务器部署使用根目录的 `deploy.sh`,由 GitHub Webhook 通知服务器触发: ```bash 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)。 @@ -84,7 +84,7 @@ DEPLOY_BRANCH=master ./deploy.sh | 偏好分类 | 阅读分类占比饼图 | | 偏好作者 | 阅读最多的作者排行 | | 读书排行 | 阅读时长最久的书籍 TOP 20 | -| 书架 | 全部书架书籍展示,支持分类筛选和已读完筛选 | +| 书架 | 每页 48 本,底部页码分页,支持按最近阅读、书名或作者排序 | | 笔记概览 | 有笔记的书籍及划线/想法/书签统计 | ## 数据库表结构 diff --git a/deploy/nginx-github-webhook.conf b/deploy/nginx-github-webhook.conf new file mode 100644 index 0000000..b1e3d72 --- /dev/null +++ b/deploy/nginx-github-webhook.conf @@ -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; +} diff --git a/deploy/weread-webhook.service b/deploy/weread-webhook.service new file mode 100644 index 0000000..1c58d81 --- /dev/null +++ b/deploy/weread-webhook.service @@ -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 diff --git a/deploy_webhook.py b/deploy_webhook.py new file mode 100644 index 0000000..3b7a809 --- /dev/null +++ b/deploy_webhook.py @@ -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")), + ) diff --git a/docs/API.md b/docs/API.md index 6c51f65..b2f3399 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,7 +7,7 @@ | 数据 | 来源 | 说明 | |------|------|------| -| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间 | +| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间和置顶标记 | | 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 | | 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 | | 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 | @@ -17,6 +17,8 @@ MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。 +当前书架页一次读取书架电子书,在前端按状态筛选后每页显示 48 本。排序选项为最近阅读(默认)、书名升序、书名降序和作者升序;置顶书籍始终排在前面。接口仍支持 `limit` / `offset` 参数供其他调用者分页,书架页使用全量结果以供书架、阅读目标和发现功能共享。 + ## 接口清单 | 方法 | 路径 | 参数 | 说明 | diff --git a/docs/code-design.md b/docs/code-design.md index 4b09ab5..2a79267 100644 --- a/docs/code-design.md +++ b/docs/code-design.md @@ -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`。 -例:用户将已验收代码合并并推送到 `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 不改变业务口径。 +## 正式看板书架排序与分页 + +正式看板从 `/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 存储方式不适用于正式目标。 diff --git a/docs/deploy-master.md b/docs/deploy-master.md index 5c9ba77..3f8a327 100644 --- a/docs/deploy-master.md +++ b/docs/deploy-master.md @@ -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` -- `SERVER_PORT` -- `SERVER_SSH_KEY` -- `SERVER_USER` +Webhook 服务配置文件 `/etc/weread-webhook.env`: -可选再配置两个仓库 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` -- `SERVICE_NAME`:systemd 服务名,例如 `weread.service` +复制 `deploy/weread-webhook.service` 到 `/etc/systemd/system/`,并执行: -未配置 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`。 -如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。SSH 用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。 +如果这些变量放在项目目录的 `.env` 文件中,部署脚本会先加载 `.env`,再执行数据库迁移。Webhook 运行用户需要能执行 `systemctl restart <服务名>`;非 root 用户需要配置免密 `sudo systemctl restart/status <服务名>`。 `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_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。 diff --git a/docs/technical-guide.md b/docs/technical-guide.md index 9807551..c5a5d94 100644 --- a/docs/technical-guide.md +++ b/docs/technical-guide.md @@ -109,7 +109,7 @@ python3 -c 'import secrets; print(secrets.token_urlsafe(48))' 应用本身未实现登录尝试限流。公网部署还应在 Nginx 或其他可信入口对 `POST /auth/login` 配置频率限制,不能信任客户端自行传入的转发 IP。 -## GitHub 自动部署 +## GitHub Webhook 自动部署 服务器部署使用根目录的 `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 ``` -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 | 说明 | -|---|---| -| `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` | +服务器 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、仓库和分支,并在已有部署运行时拒绝重复启动。 如果在服务器手动部署,可执行: @@ -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 宽,概览、书架和目标页均未出现横向溢出。 -## 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 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index efbe5af..b800cba 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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:接口调整后页面请求失败 diff --git a/legacy/classic/static/css/style.css b/legacy/classic/static/css/style.css index 92e4e86..58d9767 100644 --- a/legacy/classic/static/css/style.css +++ b/legacy/classic/static/css/style.css @@ -146,7 +146,7 @@ body { 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); color: var(--text-primary); line-height: 1.6; @@ -154,14 +154,34 @@ body { } /* 所有主题相关元素平滑过渡 */ -.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .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 { +.navbar, +.nav-link, +.stat-card, +.chart-card, +.top-book-card, +.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, - 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 { @@ -264,12 +284,25 @@ body { transform: scale(1.08); } -.theme-toggle .icon-sun { display: none; } -.theme-toggle .icon-moon { display: block; } +.theme-toggle .icon-sun { + display: none; +} -[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); } +.theme-toggle .icon-moon { + display: block; +} + +[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 { @@ -382,7 +415,7 @@ body { } .shelf-cat-parent.active .shelf-cat-count { - background: rgba(255,255,255,0.25); + background: rgba(255, 255, 255, 0.25); color: #fff; } @@ -403,8 +436,15 @@ body { } @keyframes fadeIn { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .shelf-cat-sub { @@ -443,7 +483,7 @@ body { } .shelf-cat-sub.active .shelf-cat-count-sm { - background: rgba(255,255,255,0.25); + background: rgba(255, 255, 255, 0.25); color: #fff; } @@ -617,8 +657,15 @@ body { } @keyframes fadeInTab { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } } /* ===== 读书排行(竖向列表) ===== */ @@ -1090,7 +1137,7 @@ body { color: var(--alert-text); font-size: 14px; 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; } @@ -1111,8 +1158,15 @@ body { } @keyframes slideDown { - from { transform: translateY(-100%); opacity: 0; } - to { transform: translateY(0); opacity: 1; } + from { + transform: translateY(-100%); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } } /* ===== 加载动画 ===== */ @@ -1133,7 +1187,9 @@ body { } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* ===== 推荐发现区 ===== */ @@ -1756,7 +1812,7 @@ body { } .thought-filter-btn.active .filter-count { - background: rgba(255,255,255,0.25); + background: rgba(255, 255, 255, 0.25); color: #fff; } @@ -1964,78 +2020,100 @@ body { .nav-links { display: none; } + .hero-title { font-size: 24px; } + .hero-stats { gap: 24px; } + .hero-stat-value { font-size: 32px; } + .charts-grid { grid-template-columns: 1fr; } + .chart-card.large { grid-column: auto; } + .book-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 12px; } + .top-books-list { gap: 8px; } + .top-book-row { padding: 8px 12px; gap: 10px; } + .top-book-mini-cover { width: 36px; height: 48px; } + .top-book-rank-num { width: 28px; height: 28px; font-size: 13px; } + .nav-update { display: none; } + .global-search-wrapper { max-width: 100%; } + .thoughts-toolbar { flex-direction: column; align-items: stretch; } + .thoughts-controls { flex-direction: column; gap: 8px; } + .thoughts-search-input { width: 100%; } + .thoughts-sort-select { width: 100%; } + .shelf-search { width: 140px; } + .shelf-categories { gap: 6px; } + .shelf-cat-parent { font-size: 12px; padding: 4px 10px; } + .shelf-cat-sub { font-size: 11px; padding: 3px 9px; } + .shelf-cat-subs { padding-left: 10px; } + .back-to-top { bottom: 20px; right: 20px; @@ -2043,14 +2121,17 @@ body { height: 40px; font-size: 18px; } + .thoughts-page-header { top: 0; } + .thoughts-book-cover { width: 48px; height: 64px; } + .thoughts-book-title { font-size: 16px; } -} +} \ No newline at end of file diff --git a/legacy/classic/static/js/app.js b/legacy/classic/static/js/app.js index daf6e68..50a57d0 100644 --- a/legacy/classic/static/js/app.js +++ b/legacy/classic/static/js/app.js @@ -476,11 +476,13 @@ async function loadYearlyTrend() { barWidth: "50%", itemStyle: { 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: [ { offset: 0, color: c.accentLight }, { offset: 1, color: c.accent }, - ] }, + ] + }, }, 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 }, itemStyle: { color: c.accent }, areaStyle: { - color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, + color: { + type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: c.accent + "4D" }, { offset: 1, color: c.accent + "05" }, - ] }, + ] + }, }, }], }); @@ -650,11 +654,13 @@ async function loadAuthors() { barWidth: "50%", itemStyle: { 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: [ { offset: 0, color: c.accentLight }, { offset: 1, color: c.accent }, - ] }, + ] + }, }, label: { show: true, position: "right", color: c.label, fontSize: 12, formatter: "{c}本" }, }], diff --git a/legacy/classic/templates/index.html b/legacy/classic/templates/index.html index 803e317..ceb5c79 100644 --- a/legacy/classic/templates/index.html +++ b/legacy/classic/templates/index.html @@ -1,5 +1,6 @@ + @@ -8,7 +9,7 @@ + @@ -208,4 +216,5 @@ - + + \ No newline at end of file diff --git a/reading_api.py b/reading_api.py index f3e507b..4130ba2 100644 --- a/reading_api.py +++ b/reading_api.py @@ -364,6 +364,7 @@ def make_book( author=None, category=None, finish=0, + is_top=0, read_update_time=0, cover_url=None, deep_link=None, @@ -401,6 +402,7 @@ def make_book( "read_time_str": duration_str(read_time) if read_time else "", "read_update_time": ts_to_date(read_update_time), "finish_reading": finish, + "is_top": int(is_top or 0), "deep_link": deep_link or "", } @@ -589,6 +591,7 @@ def shelf_books(conn, with_progress=False, progress_limit=30): row.get("author"), row.get("category"), finish=row.get("finishReading"), + is_top=row.get("isTop"), read_update_time=row.get("readUpdateTime"), cover_url=row.get("cover"), deep_link=row.get("deepLink"), diff --git a/static/css/style.css b/static/css/style.css index 6d475a0..c4b1199 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -146,7 +146,7 @@ body { 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); color: var(--text-primary); line-height: 1.6; @@ -154,14 +154,34 @@ body { } /* 所有主题相关元素平滑过渡 */ -.navbar, .nav-link, .stat-card, .chart-card, .top-book-card, .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 { +.navbar, +.nav-link, +.stat-card, +.chart-card, +.top-book-card, +.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, - 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 { @@ -264,12 +284,25 @@ body { transform: scale(1.08); } -.theme-toggle .icon-sun { display: none; } -.theme-toggle .icon-moon { display: block; } +.theme-toggle .icon-sun { + display: none; +} -[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); } +.theme-toggle .icon-moon { + display: block; +} + +[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 { @@ -382,7 +415,7 @@ body { } .shelf-cat-parent.active .shelf-cat-count { - background: rgba(255,255,255,0.25); + background: rgba(255, 255, 255, 0.25); color: #fff; } @@ -403,8 +436,15 @@ body { } @keyframes fadeIn { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .shelf-cat-sub { @@ -443,7 +483,7 @@ body { } .shelf-cat-sub.active .shelf-cat-count-sm { - background: rgba(255,255,255,0.25); + background: rgba(255, 255, 255, 0.25); color: #fff; } @@ -617,8 +657,15 @@ body { } @keyframes fadeInTab { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } } /* ===== 读书排行(竖向列表) ===== */ @@ -1101,7 +1148,7 @@ body { color: var(--alert-text); font-size: 14px; 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; } @@ -1122,8 +1169,15 @@ body { } @keyframes slideDown { - from { transform: translateY(-100%); opacity: 0; } - to { transform: translateY(0); opacity: 1; } + from { + transform: translateY(-100%); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } } /* ===== 加载动画 ===== */ @@ -1144,7 +1198,9 @@ body { } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* ===== 推荐发现区 ===== */ @@ -1767,7 +1823,7 @@ body { } .thought-filter-btn.active .filter-count { - background: rgba(255,255,255,0.25); + background: rgba(255, 255, 255, 0.25); color: #fff; } @@ -1975,81 +2031,104 @@ body { .nav-links { display: none; } + .hero-title { font-size: 24px; } + .hero-stats { gap: 24px; } + .hero-stat-value { font-size: 32px; } + .charts-grid { grid-template-columns: 1fr; } + .chart-card.large { grid-column: auto; } + .book-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 12px; } + .top-books-list { gap: 8px; } + .top-book-row { padding: 8px 12px; gap: 10px; } + .top-book-mini-cover { width: 36px; height: 48px; } + .top-book-rank-num { width: 28px; height: 28px; font-size: 13px; } + .nav-update { display: none; } + .global-search-wrapper { max-width: 100%; } + .thoughts-toolbar { flex-direction: column; align-items: stretch; } + .thoughts-controls { flex-direction: column; gap: 8px; } + .thoughts-search-input { width: 100%; } + .thoughts-sort-select { width: 100%; } + .shelf-search { width: 140px; } + .shelf-filters { gap: 10px; } + .shelf-categories { gap: 6px; } + .shelf-cat-parent { font-size: 12px; padding: 4px 10px; } + .shelf-cat-sub { font-size: 11px; padding: 3px 9px; } + .shelf-cat-subs { padding-left: 10px; } + .back-to-top { bottom: 20px; right: 20px; @@ -2057,14 +2136,17 @@ body { height: 40px; font-size: 18px; } + .thoughts-page-header { top: 0; } + .thoughts-book-cover { width: 48px; height: 64px; } + .thoughts-book-title { font-size: 16px; } -} +} \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css index 0050bff..a0b96f6 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,94 +1,2940 @@ +:root { + --bg: #f7f8fa; + --panel: #fff; + --text: #242a34; + --muted: #9298a4; + --line: #edf0f3; + --accent: #356ce5; + --soft: #edf3ff; + --sidebar: #fff; + --shadow: 0 8px 32px #24355005; + --quote: #f8f6f1; + --green: #449682 +} + +[data-theme=dark] { + --bg: #14171e; + --panel: #1d222c; + --text: #e7eaf0; + --muted: #8892a4; + --line: #2c323f; + --accent: #80a5ff; + --soft: #26344e; + --sidebar: #191d26; + --shadow: 0 8px 32px #0001; + --quote: #292820; + --green: #75c6ac; + color-scheme: dark +} + +* { + box-sizing: border-box +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: 14px; + transition: background .25s, color .25s +} + +button, +input { + font: inherit +} + +button { + cursor: pointer; + color: inherit +} + +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 3px solid var(--accent); + outline-offset: 4px +} + +button { + border: 0; + background: none +} + +svg { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; + vertical-align: middle +} + +.sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 218px; + padding: 39px 23px 24px; + background: var(--sidebar); + border-right: 1px solid var(--line); + display: flex; + flex-direction: column; + z-index: 5 +} + +.logo { + display: flex; + align-items: center; + gap: 11px; + font-size: 24px; + font-weight: 650; + letter-spacing: 2px +} + +.logo-mark { + background: #356ce5; + color: white; + width: 37px; + height: 37px; + display: grid; + place-items: center; + border-radius: 12px +} + +.logo-mark svg { + width: 25px; + height: 25px +} + +.logo small { + display: block; + letter-spacing: 2px; + font-size: 9px; + color: var(--muted); + margin-top: 3px; + font-weight: 500 +} + +.navlabel { + font-size: 10px; + color: var(--muted); + letter-spacing: 1.7px; + margin: 53px 14px 15px +} + +.nav { + display: flex; + flex-direction: column; + gap: 9px +} + +.nav button { + text-align: left; + padding: 14px 17px; + border-radius: 9px; + display: flex; + align-items: center; + gap: 13px; + color: #818997 +} + +.nav button.active { + color: var(--accent); + background: var(--soft); + font-weight: 600 +} + +.nav button:hover { + background: var(--soft) +} + +.nav em { + margin-left: auto; + font-size: 10px; + font-style: normal; + background: var(--line); + padding: 2px 6px; + border-radius: 4px +} + +.sidebar-bottom { + margin-top: auto +} + +.connection { + padding: 16px 12px; + border: 1px solid var(--line); + border-radius: 10px; + font-size: 12px; + line-height: 1.9 +} + +.dot { + display: inline-block; + background: var(--green); + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 7px +} + +.connection small { + display: block; + font-size: 10px; + color: var(--muted) +} + +.profile { + display: flex; + align-items: center; + gap: 10px; + margin-top: 24px; + padding: 0 8px +} + +.avatar { + width: 33px; + height: 33px; + border-radius: 50%; + display: grid; + place-items: center; + background: #e4eadf; + color: #5e7252; + font-family: Georgia; + font-size: 18px +} + +.profile small { + display: block; + font-size: 10px; + color: var(--muted); + margin-top: 4px +} + +.main { + margin-left: 218px; + padding: 0 40px 32px; + max-width: 1800px +} + +.topbar { + height: 91px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line) +} + +.breadcrumb { + font-size: 12px; + color: var(--muted) +} + +.breadcrumb span { + color: var(--text); + margin-left: 12px +} + +.top-actions { + display: flex; + align-items: center; + gap: 20px +} + +.searchbox { + display: flex; + align-items: center; + gap: 10px; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 12px +} + +.searchbox input { + background: none; + border: 0; + outline: none; + width: 180px; + color: var(--text); + font-size: 11px +} + +.key { + font-size: 10px; + border: 1px solid var(--line); + border-radius: 4px; + padding: 2px 4px +} + +.iconbtn { + padding: 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel) +} + +.welcome { + display: flex; + justify-content: space-between; + align-items: center; + margin: 33px 0 27px +} + +.eyebrow { + font-size: 10px; + letter-spacing: 2px; + color: var(--muted); + margin-bottom: 10px +} + +h1 { + font-size: 27px; + letter-spacing: .5px; + margin: 0 0 10px; + font-weight: 600 +} + +h1 span { + font-family: Georgia; + font-size: 25px; + color: #c3a977 +} + +p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.8 +} + +.welcome-right { + display: flex; + gap: 12px; + align-items: center +} + +.demo-tag { + font-size: 10px; + padding: 6px 9px; + background: var(--quote); + border: 1px solid var(--line); + border-radius: 5px; + color: var(--muted) +} + +.primary { + background: #356ce5; + color: white; + padding: 11px 16px; + border-radius: 7px; + font-size: 12px +} + +.primary svg { + width: 15px; + height: 15px; + margin-right: 6px +} + +.stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 18px; + margin-bottom: 23px +} + +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: var(--shadow) +} + +.stat { + padding: 21px 23px +} + +.stat-label { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--muted); + font-size: 12px +} + +.stat-label svg { + width: 17px; + height: 17px; + color: #9aa8bf +} + +.stat-value { + font-family: Georgia, serif; + font-size: 34px; + letter-spacing: -1px; + margin: 14px 0 12px +} + +.stat-value small { + font-family: "PingFang SC", sans-serif; + font-size: 11px; + color: var(--muted); + letter-spacing: 0; + margin-left: 5px +} + +.stat-foot { + font-size: 10px; + color: var(--muted) +} + +.stat-foot b { + color: var(--green); + font-weight: 500; + margin-right: 7px +} + +.columns { + display: grid; + grid-template-columns: minmax(0, 1fr) 296px; + gap: 23px +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px +} + +.section-head h2 { + font-size: 15px; + font-weight: 600; + margin: 0 +} + +.section-head h2 small { + font-weight: 400; + color: var(--muted); + font-size: 10px; + margin-left: 10px +} + +.text-button { + font-size: 11px; + color: var(--muted); + padding: 3px +} + +.text-button:hover { + color: var(--accent) +} + +.trend { + padding: 24px 25px; + margin-bottom: 23px +} + +.segmented { + background: var(--bg); + padding: 3px; + display: flex; + border-radius: 6px +} + +.segmented button { + font-size: 10px; + padding: 5px 12px; + color: var(--muted); + border-radius: 4px +} + +.segmented button.active { + background: var(--panel); + color: var(--accent); + box-shadow: 0 1px 5px #0001 +} + +.trend-total { + font-family: Georgia; + font-size: 25px; + margin-bottom: 10px +} + +.trend-total small { + font-family: "PingFang SC"; + font-size: 10px; + color: var(--muted); + margin-left: 9px +} + +.chart { + height: 130px; + position: relative; + padding-left: 30px +} + +.gridlines { + position: absolute; + inset: 0 0 15px 30px; + display: flex; + flex-direction: column; + justify-content: space-between +} + +.gridlines i { + border-top: 1px dashed var(--line); + position: relative +} + +.gridlines i:before { + content: attr(data-label); + position: absolute; + left: -29px; + top: -6px; + color: var(--muted); + font-size: 9px; + font-style: normal +} + +.bars { + display: flex; + align-items: end; + justify-content: space-around; + height: 100%; + gap: 14px; + position: relative; + padding-bottom: 15px +} + +.bar { + height: var(--h); + max-width: 33px; + flex: 1; + background: #dbe6fc; + border-radius: 4px 4px 0 0; + position: relative; + transition: height .35s +} + +.bar:nth-child(5) { + background: #4d7ce7 +} + +.bar span { + position: absolute; + top: calc(100% + 9px); + left: 50%; + transform: translateX(-50%); + font-size: 9px; + color: var(--muted); + white-space: nowrap +} + +.bar:hover { + background: #7199ef +} + +.bar:hover:before { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + background: var(--text); + color: var(--panel); + padding: 5px 7px; + font-size: 10px; + border-radius: 4px +} + +.shelf { + padding: 24px 25px +} + +.tabs { + display: flex; + gap: 25px; + border-bottom: 1px solid var(--line); + margin-top: -4px; + margin-bottom: 24px +} + +.tabs button { + padding: 0 0 12px; + font-size: 11px; + color: var(--muted); + border-bottom: 2px solid transparent +} + +.tabs button.active { + color: var(--accent); + border-bottom-color: var(--accent) +} + +.books { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 23px +} + +.book { + text-align: left; + padding: 0; + min-width: 0; + transition: transform .2s +} + +.book:hover { + transform: translateY(-5px) +} + +.cover { + height: 160px; + width: 112px; + max-width: 100%; + border-radius: 2px 5px 5px 2px; + position: relative; + padding: 15px 12px; + overflow: hidden; + box-shadow: 5px 8px 13px #17212c20, inset 4px 0 3px #ffffff25; + display: flex; + flex-direction: column; + color: #fff +} + +.cover:after { + content: ""; + position: absolute; + inset: 0; + border-left: 4px solid #0002; + pointer-events: none +} + +.cover .tiny { + font-size: 6px; + letter-spacing: 1px; + opacity: .8 +} + +.cover .title { + font-family: "Songti SC", "STSong", serif; + line-height: 1.3; + font-size: 22px; + margin-top: 15px; + font-weight: 700 +} + +.cover .author { + font-size: 7px; + margin-top: 10px +} + +.cover .publisher { + font-size: 6px; + margin-top: auto; + letter-spacing: 1px +} + +.c1 { + background: #efe9d8; + color: #374432 +} + +.c1 .title { + font-size: 19px; + letter-spacing: 1px; + margin-top: 15px; + z-index: 1 +} + +.c1:before { + content: ""; + position: absolute; + width: 130px; + height: 110px; + border: 1px solid #73876a66; + border-radius: 50%; + left: -29px; + bottom: -24px; + box-shadow: 0 0 0 8px #72826313, 0 0 0 18px #72826313, 0 0 0 30px #72826313 +} + +.c2 { + background: #182b3b +} + +.c2 .title { + font-size: 37px; + letter-spacing: 6px +} + +.c2:before { + content: ""; + position: absolute; + left: 20px; + bottom: -42px; + width: 115px; + height: 115px; + border: 1px solid #b8855144; + border-radius: 50%; + box-shadow: 0 0 0 8px #b8855122, 0 0 0 17px #b8855111 +} + +.c3 { + background: #db502e +} + +.c3 .title { + font-size: 19px; + color: #fff3d7 +} + +.c3:before { + content: ""; + position: absolute; + width: 60px; + height: 60px; + background: #f4c66a; + bottom: 13px; + right: -8px; + border-radius: 50% +} + +.c4 { + background: #f0eee2; + color: #282722 +} + +.c4 .title { + font-size: 24px +} + +.c4:before { + content: ""; + position: absolute; + width: 90px; + height: 90px; + bottom: -24px; + right: -25px; + background: repeating-linear-gradient(45deg, #3d6274 0 5px, transparent 5px 10px); + transform: rotate(-15deg) +} + +.book h3 { + font-size: 12px; + font-weight: 500; + margin: 14px 0 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis +} + +.book p { + font-size: 10px +} + +.progress { + height: 3px; + background: var(--line); + border-radius: 3px; + margin-top: 13px +} + +.progress i { + display: block; + height: 100%; + background: #8aa7d8; + width: var(--progress) +} + +.book-meta { + font-size: 9px; + color: var(--muted); + display: flex; + justify-content: space-between; + margin-top: 7px +} + +.rightcol { + display: flex; + flex-direction: column; + gap: 23px +} + +.goal { + padding: 24px +} + +.goal-ring { + margin: 12px auto 16px; + width: 142px; + height: 142px; + border-radius: 50%; + background: conic-gradient(#679483 0 216deg, var(--line) 216deg); + padding: 9px; + transform: rotate(-90deg) +} + +.ring-inner { + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--panel); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transform: rotate(90deg) +} + +.ring-inner strong { + font-family: Georgia; + font-size: 36px; + font-weight: 400 +} + +.ring-inner small { + color: var(--muted); + font-size: 10px; + margin-top: 5px +} + +.goal-caption { + text-align: center; + font-size: 11px; + margin-bottom: 18px +} + +.goal-caption span { + color: var(--green) +} + +.goal-footer { + padding-top: 15px; + border-top: 1px solid var(--line); + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--muted) +} + +.goal-footer b { + color: var(--text); + font-weight: 500 +} + +.notes { + padding: 24px +} + +.note { + border-left: 2px solid #d7bd7e; + padding-left: 13px; + margin: 0 0 19px +} + +.note blockquote { + font-family: "Songti SC", "STSong", serif; + font-size: 13px; + line-height: 1.9; + margin: 0 0 11px +} + +.note small { + font-size: 9px; + color: var(--muted) +} + +.note:last-child { + margin-bottom: 0 +} + +.quote { + padding: 20px 24px; + background: var(--quote); + border-radius: 12px; + position: relative +} + +.quote .eyebrow { + font-size: 9px; + letter-spacing: 1.5px +} + +.quote p { + font-family: "Songti SC", serif; + color: var(--text); + font-size: 13px; + line-height: 1.9 +} + +.quote small { + display: block; + text-align: right; + margin-top: 12px; + color: var(--muted); + font-size: 9px +} + +.footer { + display: flex; + justify-content: space-between; + margin-top: 24px; + font-size: 9px; + color: var(--muted) +} + +.empty { + padding: 40px; + text-align: center; + color: var(--muted); + grid-column: 1/-1 +} + +.notes-page { + display: grid; + gap: 20px +} + +.notes-page .note { + padding: 20px; + margin: 0 +} + +.hidden { + display: none !important +} + +dialog { + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + border-radius: 18px; + padding: 32px; + width: 520px; + max-width: 90vw; + box-shadow: 0 30px 100px #0003 +} + +dialog::backdrop { + background: #10182877; + backdrop-filter: blur(5px) +} + +.dialog-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px +} + +.dialog-top h2 { + margin: 0; + font-size: 18px +} + +.detail-body { + display: flex; + gap: 26px; + margin-bottom: 22px +} + +.detail-info { + flex: 1 +} + +.detail-info h3 { + font-size: 22px; + margin: 8px 0 +} + +.detail-info p { + margin: 8px 0 +} + +.detail-desc { + font-size: 13px; + line-height: 2; + color: var(--muted) +} + +#toast { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: var(--text); + color: var(--panel); + padding: 13px 23px; + border-radius: 10px; + z-index: 30; + font-size: 12px +} + +.fade { + animation: fade .5s ease both +} + +@keyframes fade { + from { + opacity: 0; + transform: translateY(7px) + } + + to { + opacity: 1; + transform: translateY(0) + } +} + +@media(min-width:1500px) { + .main { + padding-left: 55px; + padding-right: 55px + } + + .columns { + grid-template-columns: minmax(0, 1fr) 330px + } + + .cover { + height: 185px; + width: 130px + } + + .chart { + height: 160px + } +} + +@media(max-width:1150px) { + .sidebar { + width: 180px; + padding-left: 16px; + padding-right: 16px + } + + .main { + margin-left: 180px; + padding: 0 24px 24px + } + + .columns { + grid-template-columns: minmax(0, 1fr) 250px; + gap: 16px + } + + .books { + gap: 12px + } + + .cover { + height: 140px + } + + .stats { + gap: 12px + } + + .stat { + padding: 18px 15px + } + + .shelf, + .trend { + padding: 20px + } + + .searchbox input { + width: 130px + } + + .welcome-right .demo-tag { + display: none + } +} + +@media(max-width:900px) { + .stats { + grid-template-columns: repeat(2, 1fr) + } + + .sidebar { + width: 76px; + padding: 28px 12px + } + + .logo { + justify-content: center + } + + .logo>div:last-child, + .navlabel, + .nav button span, + .nav em, + .sidebar-bottom { + display: none + } + + .nav { + margin-top: 45px + } + + .nav button { + padding: 14px; + justify-content: center + } + + .main { + margin-left: 76px; + padding: 0 22px 24px + } + + .columns { + grid-template-columns: 1fr + } + + .rightcol { + display: grid; + grid-template-columns: 1fr 1fr + } + + .quote { + grid-column: 1/-1 + } + + .cover { + height: 165px + } + + .welcome-right .primary { + font-size: 0 + } + + .welcome-right .primary svg { + margin: 0 + } + + .stat-value { + font-size: 28px + } + + .topbar { + height: 75px + } +} + +@media(max-width:600px) { + .main { + padding: 0 16px 20px; + margin-left: 60px + } + + .sidebar { + width: 60px; + padding: 25px 6px + } + + .logo-mark { + width: 33px; + height: 33px + } + + .nav button { + padding: 12px + } + + .searchbox { + padding: 8px + } + + .searchbox input { + width: 112px + } + + .key, + .breadcrumb, + .top-actions>.avatar { + display: none + } + + .top-actions { + width: 100%; + justify-content: space-between; + gap: 8px + } + + .welcome { + margin-top: 25px + } + + h1 { + font-size: 21px + } + + .welcome p { + font-size: 10px + } + + .eyebrow { + font-size: 8px + } + + .stats { + grid-template-columns: repeat(2, 1fr) + } + + .stat { + padding: 16px + } + + .stat-value { + margin: 10px 0 + } + + .rightcol { + display: flex + } + + .books { + grid-template-columns: repeat(2, 1fr); + gap: 24px 15px + } + + .cover { + height: 160px; + width: 112px + } + + .section-head h2 small { + display: none + } + + .tabs { + gap: 18px + } + + .footer { + font-size: 8px + } + + .footer span:last-child { + display: none + } + + .detail-body { + gap: 16px + } + + .detail-info h3 { + font-size: 18px + } +} + +@media(prefers-reduced-motion:reduce) { + + *, + *:before, + *:after { + animation: none !important; + transition: none !important + } +} -:root{--bg:#f7f8fa;--panel:#fff;--text:#242a34;--muted:#9298a4;--line:#edf0f3;--accent:#356ce5;--soft:#edf3ff;--sidebar:#fff;--shadow:0 8px 32px #24355005;--quote:#f8f6f1;--green:#449682} -[data-theme=dark]{--bg:#14171e;--panel:#1d222c;--text:#e7eaf0;--muted:#8892a4;--line:#2c323f;--accent:#80a5ff;--soft:#26344e;--sidebar:#191d26;--shadow:0 8px 32px #0001;--quote:#292820;--green:#75c6ac;color-scheme:dark} -*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;transition:background .25s,color .25s}button,input{font:inherit}button{cursor:pointer;color:inherit}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:4px}button{border:0;background:none}svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}.sidebar{position:fixed;inset:0 auto 0 0;width:218px;padding:39px 23px 24px;background:var(--sidebar);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:5}.logo{display:flex;align-items:center;gap:11px;font-size:24px;font-weight:650;letter-spacing:2px}.logo-mark{background:#356ce5;color:white;width:37px;height:37px;display:grid;place-items:center;border-radius:12px}.logo-mark svg{width:25px;height:25px}.logo small{display:block;letter-spacing:2px;font-size:9px;color:var(--muted);margin-top:3px;font-weight:500}.navlabel{font-size:10px;color:var(--muted);letter-spacing:1.7px;margin:53px 14px 15px}.nav{display:flex;flex-direction:column;gap:9px}.nav button{text-align:left;padding:14px 17px;border-radius:9px;display:flex;align-items:center;gap:13px;color:#818997}.nav button.active{color:var(--accent);background:var(--soft);font-weight:600}.nav button:hover{background:var(--soft)}.nav em{margin-left:auto;font-size:10px;font-style:normal;background:var(--line);padding:2px 6px;border-radius:4px}.sidebar-bottom{margin-top:auto}.connection{padding:16px 12px;border:1px solid var(--line);border-radius:10px;font-size:12px;line-height:1.9}.dot{display:inline-block;background:var(--green);width:6px;height:6px;border-radius:50%;margin-right:7px}.connection small{display:block;font-size:10px;color:var(--muted)}.profile{display:flex;align-items:center;gap:10px;margin-top:24px;padding:0 8px}.avatar{width:33px;height:33px;border-radius:50%;display:grid;place-items:center;background:#e4eadf;color:#5e7252;font-family:Georgia;font-size:18px}.profile small{display:block;font-size:10px;color:var(--muted);margin-top:4px}.main{margin-left:218px;padding:0 40px 32px;max-width:1800px}.topbar{height:91px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.breadcrumb{font-size:12px;color:var(--muted)}.breadcrumb span{color:var(--text);margin-left:12px}.top-actions{display:flex;align-items:center;gap:20px}.searchbox{display:flex;align-items:center;gap:10px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:9px 12px}.searchbox input{background:none;border:0;outline:none;width:180px;color:var(--text);font-size:11px}.key{font-size:10px;border:1px solid var(--line);border-radius:4px;padding:2px 4px}.iconbtn{padding:7px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}.welcome{display:flex;justify-content:space-between;align-items:center;margin:33px 0 27px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);margin-bottom:10px}h1{font-size:27px;letter-spacing:.5px;margin:0 0 10px;font-weight:600}h1 span{font-family:Georgia;font-size:25px;color:#c3a977}p{margin:0;color:var(--muted);font-size:12px;line-height:1.8}.welcome-right{display:flex;gap:12px;align-items:center}.demo-tag{font-size:10px;padding:6px 9px;background:var(--quote);border:1px solid var(--line);border-radius:5px;color:var(--muted)}.primary{background:#356ce5;color:white;padding:11px 16px;border-radius:7px;font-size:12px}.primary svg{width:15px;height:15px;margin-right:6px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:23px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.stat{padding:21px 23px}.stat-label{display:flex;justify-content:space-between;align-items:center;color:var(--muted);font-size:12px}.stat-label svg{width:17px;height:17px;color:#9aa8bf}.stat-value{font-family:Georgia,serif;font-size:34px;letter-spacing:-1px;margin:14px 0 12px}.stat-value small{font-family:"PingFang SC",sans-serif;font-size:11px;color:var(--muted);letter-spacing:0;margin-left:5px}.stat-foot{font-size:10px;color:var(--muted)}.stat-foot b{color:var(--green);font-weight:500;margin-right:7px}.columns{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:23px}.section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.section-head h2{font-size:15px;font-weight:600;margin:0}.section-head h2 small{font-weight:400;color:var(--muted);font-size:10px;margin-left:10px}.text-button{font-size:11px;color:var(--muted);padding:3px}.text-button:hover{color:var(--accent)}.trend{padding:24px 25px;margin-bottom:23px}.segmented{background:var(--bg);padding:3px;display:flex;border-radius:6px}.segmented button{font-size:10px;padding:5px 12px;color:var(--muted);border-radius:4px}.segmented button.active{background:var(--panel);color:var(--accent);box-shadow:0 1px 5px #0001}.trend-total{font-family:Georgia;font-size:25px;margin-bottom:10px}.trend-total small{font-family:"PingFang SC";font-size:10px;color:var(--muted);margin-left:9px}.chart{height:130px;position:relative;padding-left:30px}.gridlines{position:absolute;inset:0 0 15px 30px;display:flex;flex-direction:column;justify-content:space-between}.gridlines i{border-top:1px dashed var(--line);position:relative}.gridlines i:before{content:attr(data-label);position:absolute;left:-29px;top:-6px;color:var(--muted);font-size:9px;font-style:normal}.bars{display:flex;align-items:end;justify-content:space-around;height:100%;gap:14px;position:relative;padding-bottom:15px}.bar{height:var(--h);max-width:33px;flex:1;background:#dbe6fc;border-radius:4px 4px 0 0;position:relative;transition:height .35s}.bar:nth-child(5){background:#4d7ce7}.bar span{position:absolute;top:calc(100% + 9px);left:50%;transform:translateX(-50%);font-size:9px;color:var(--muted);white-space:nowrap}.bar:hover{background:#7199ef}.bar:hover:before{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);white-space:nowrap;background:var(--text);color:var(--panel);padding:5px 7px;font-size:10px;border-radius:4px}.shelf{padding:24px 25px}.tabs{display:flex;gap:25px;border-bottom:1px solid var(--line);margin-top:-4px;margin-bottom:24px}.tabs button{padding:0 0 12px;font-size:11px;color:var(--muted);border-bottom:2px solid transparent}.tabs button.active{color:var(--accent);border-bottom-color:var(--accent)}.books{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:23px}.book{text-align:left;padding:0;min-width:0;transition:transform .2s}.book:hover{transform:translateY(-5px)}.cover{height:160px;width:112px;max-width:100%;border-radius:2px 5px 5px 2px;position:relative;padding:15px 12px;overflow:hidden;box-shadow:5px 8px 13px #17212c20,inset 4px 0 3px #ffffff25;display:flex;flex-direction:column;color:#fff}.cover:after{content:"";position:absolute;inset:0;border-left:4px solid #0002;pointer-events:none}.cover .tiny{font-size:6px;letter-spacing:1px;opacity:.8}.cover .title{font-family:"Songti SC","STSong",serif;line-height:1.3;font-size:22px;margin-top:15px;font-weight:700}.cover .author{font-size:7px;margin-top:10px}.cover .publisher{font-size:6px;margin-top:auto;letter-spacing:1px}.c1{background:#efe9d8;color:#374432}.c1 .title{font-size:19px;letter-spacing:1px;margin-top:15px;z-index:1}.c1:before{content:"";position:absolute;width:130px;height:110px;border:1px solid #73876a66;border-radius:50%;left:-29px;bottom:-24px;box-shadow:0 0 0 8px #72826313,0 0 0 18px #72826313,0 0 0 30px #72826313}.c2{background:#182b3b}.c2 .title{font-size:37px;letter-spacing:6px}.c2:before{content:"";position:absolute;left:20px;bottom:-42px;width:115px;height:115px;border:1px solid #b8855144;border-radius:50%;box-shadow:0 0 0 8px #b8855122,0 0 0 17px #b8855111}.c3{background:#db502e}.c3 .title{font-size:19px;color:#fff3d7}.c3:before{content:"";position:absolute;width:60px;height:60px;background:#f4c66a;bottom:13px;right:-8px;border-radius:50%}.c4{background:#f0eee2;color:#282722}.c4 .title{font-size:24px}.c4:before{content:"";position:absolute;width:90px;height:90px;bottom:-24px;right:-25px;background:repeating-linear-gradient(45deg,#3d6274 0 5px,transparent 5px 10px);transform:rotate(-15deg)}.book h3{font-size:12px;font-weight:500;margin:14px 0 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.book p{font-size:10px}.progress{height:3px;background:var(--line);border-radius:3px;margin-top:13px}.progress i{display:block;height:100%;background:#8aa7d8;width:var(--progress)}.book-meta{font-size:9px;color:var(--muted);display:flex;justify-content:space-between;margin-top:7px}.rightcol{display:flex;flex-direction:column;gap:23px}.goal{padding:24px}.goal-ring{margin:12px auto 16px;width:142px;height:142px;border-radius:50%;background:conic-gradient(#679483 0 216deg,var(--line) 216deg);padding:9px;transform:rotate(-90deg)}.ring-inner{width:100%;height:100%;border-radius:50%;background:var(--panel);display:flex;flex-direction:column;align-items:center;justify-content:center;transform:rotate(90deg)}.ring-inner strong{font-family:Georgia;font-size:36px;font-weight:400}.ring-inner small{color:var(--muted);font-size:10px;margin-top:5px}.goal-caption{text-align:center;font-size:11px;margin-bottom:18px}.goal-caption span{color:var(--green)}.goal-footer{padding-top:15px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font-size:10px;color:var(--muted)}.goal-footer b{color:var(--text);font-weight:500}.notes{padding:24px}.note{border-left:2px solid #d7bd7e;padding-left:13px;margin:0 0 19px}.note blockquote{font-family:"Songti SC","STSong",serif;font-size:13px;line-height:1.9;margin:0 0 11px}.note small{font-size:9px;color:var(--muted)}.note:last-child{margin-bottom:0}.quote{padding:20px 24px;background:var(--quote);border-radius:12px;position:relative}.quote .eyebrow{font-size:9px;letter-spacing:1.5px}.quote p{font-family:"Songti SC",serif;color:var(--text);font-size:13px;line-height:1.9}.quote small{display:block;text-align:right;margin-top:12px;color:var(--muted);font-size:9px}.footer{display:flex;justify-content:space-between;margin-top:24px;font-size:9px;color:var(--muted)}.empty{padding:40px;text-align:center;color:var(--muted);grid-column:1/-1}.notes-page{display:grid;gap:20px}.notes-page .note{padding:20px;margin:0}.hidden{display:none!important}dialog{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:18px;padding:32px;width:520px;max-width:90vw;box-shadow:0 30px 100px #0003}dialog::backdrop{background:#10182877;backdrop-filter:blur(5px)}.dialog-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.dialog-top h2{margin:0;font-size:18px}.detail-body{display:flex;gap:26px;margin-bottom:22px}.detail-info{flex:1}.detail-info h3{font-size:22px;margin:8px 0}.detail-info p{margin:8px 0}.detail-desc{font-size:13px;line-height:2;color:var(--muted)}#toast{position:fixed;bottom:30px;left:50%;transform:translateX(-50%);background:var(--text);color:var(--panel);padding:13px 23px;border-radius:10px;z-index:30;font-size:12px}.fade{animation:fade .5s ease both}@keyframes fade{from{opacity:0;transform:translateY(7px)}to{opacity:1;transform:translateY(0)}}@media(min-width:1500px){.main{padding-left:55px;padding-right:55px}.columns{grid-template-columns:minmax(0,1fr) 330px}.cover{height:185px;width:130px}.chart{height:160px}}@media(max-width:1150px){.sidebar{width:180px;padding-left:16px;padding-right:16px}.main{margin-left:180px;padding:0 24px 24px}.columns{grid-template-columns:minmax(0,1fr) 250px;gap:16px}.books{gap:12px}.cover{height:140px}.stats{gap:12px}.stat{padding:18px 15px}.shelf,.trend{padding:20px}.searchbox input{width:130px}.welcome-right .demo-tag{display:none}}@media(max-width:900px){.stats{grid-template-columns:repeat(2,1fr)}.sidebar{width:76px;padding:28px 12px}.logo{justify-content:center}.logo>div:last-child,.navlabel,.nav button span,.nav em,.sidebar-bottom{display:none}.nav{margin-top:45px}.nav button{padding:14px;justify-content:center}.main{margin-left:76px;padding:0 22px 24px}.columns{grid-template-columns:1fr}.rightcol{display:grid;grid-template-columns:1fr 1fr}.quote{grid-column:1/-1}.cover{height:165px}.welcome-right .primary{font-size:0}.welcome-right .primary svg{margin:0}.stat-value{font-size:28px}.topbar{height:75px}}@media(max-width:600px){.main{padding:0 16px 20px;margin-left:60px}.sidebar{width:60px;padding:25px 6px}.logo-mark{width:33px;height:33px}.nav button{padding:12px}.searchbox{padding:8px}.searchbox input{width:112px}.key,.breadcrumb,.top-actions>.avatar{display:none}.top-actions{width:100%;justify-content:space-between;gap:8px}.welcome{margin-top:25px}h1{font-size:21px}.welcome p{font-size:10px}.eyebrow{font-size:8px}.stats{grid-template-columns:repeat(2,1fr)}.stat{padding:16px}.stat-value{margin:10px 0}.rightcol{display:flex}.books{grid-template-columns:repeat(2,1fr);gap:24px 15px}.cover{height:160px;width:112px}.section-head h2 small{display:none}.tabs{gap:18px}.footer{font-size:8px}.footer span:last-child{display:none}.detail-body{gap:16px}.detail-info h3{font-size:18px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}} /* 阅读目标与内容页:与既有主题变量共用色彩。 */ -:root{--muted:#717b8b}[data-theme=dark]{--muted:#a2adbf}h3{font-size:14px;margin:0}button:disabled{opacity:.45;cursor:not-allowed}.demo-banner{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:18px}.welcome{margin-top:26px}.page-actions{display:flex;align-items:center;gap:10px}.secondary{padding:10px 15px;border:1px solid var(--line);background:var(--panel);border-radius:7px;font-size:12px}.pill{display:inline-block;padding:5px 8px;font-size:10px;border-radius:5px;background:var(--soft);color:var(--accent);white-space:nowrap}.pill.green{background:#6a9a8219;color:var(--green)}.pill.amber{background:#c3923919;color:#b78a3f}.hint{font-size:11px;color:var(--muted);line-height:1.8}.panel{padding:24px}.panel+.panel{margin-top:20px}.toolbar{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin:0 0 22px}.toolbar .tabs{margin:0;flex-wrap:wrap}.toolbar input,.toolbar select,.field input,select{border:1px solid var(--line);border-radius:7px;padding:10px 12px;background:var(--panel);color:var(--text);font:inherit;font-size:12px}.toolbar select{max-width:100%}.field{display:flex;flex-direction:column;gap:8px;font-size:12px;margin-bottom:18px}.field input{width:100%;background:var(--bg)}.date-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px}.metrics-row{display:flex;gap:24px;padding:18px 0;margin-bottom:10px;color:var(--muted);font-size:11px}.metrics-row strong{font-family:Georgia;font-size:28px;color:var(--text);margin-right:6px;font-weight:400}.goals-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.goal-card{padding:25px;position:relative;overflow:hidden}.goal-card:before{content:'';position:absolute;inset:0 auto 0 0;width:3px;background:var(--green)}.goal-card h3{font-size:19px;margin:17px 0 8px;word-break:break-word}.goal-card .section-head{margin-bottom:0}.goal-date{font-size:11px;color:var(--muted)}.cover-stack{display:flex;gap:0;margin:24px 0 20px;height:92px;padding-left:4px}.mini-cover{width:66px;height:92px;padding:10px 8px;border-radius:2px 4px 4px 2px;box-shadow:4px 5px 10px #0002;display:flex;flex-direction:column;flex-shrink:0;position:relative;overflow:hidden;border-left:3px solid #0002}.cover-stack .mini-cover{margin-left:-4px;transform:rotate(-3deg);transform-origin:bottom}.cover-stack .mini-cover:nth-child(2n){transform:rotate(4deg)}.mini-cover b{font-family:'Songti SC',serif;font-size:12px;position:relative;z-index:1;line-height:1.5}.mini-cover small{font-size:6px;position:relative;margin-top:auto}.stack-more{align-self:center;margin-left:18px;color:var(--muted);font-size:12px}.goal-progress{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--muted);margin:12px 0 9px}.goal-progress strong{font-size:22px;color:var(--text);font-family:Georgia;font-weight:400}.goal-card .progress{height:5px;margin:0 0 19px}.goal-card .progress i{background:var(--green)}.goal-card-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--line);padding-top:16px}.goal-card-footer small{font-size:10px;color:var(--muted)}.goal-card-footer button{font-size:11px;color:var(--accent)}.goal-intro{display:grid;grid-template-columns:1fr auto;gap:28px;background:var(--quote);padding:26px 30px;margin-bottom:24px;border:1px solid var(--line);border-radius:12px}.goal-intro h2{font-family:'Songti SC',serif;font-size:23px;font-weight:500;margin:0 0 10px}.goal-intro p{max-width:500px}.goal-intro .symbol{font-family:Georgia;font-size:65px;align-self:center;color:#819583}.goal-summary-row{display:block;text-align:left;width:100%;padding:16px 0;border-top:1px solid var(--line)}.goal-summary-row strong{font-size:12px;font-weight:500}.goal-summary-row small{display:block;margin-top:7px;color:var(--muted);font-size:10px}.goal-summary-row .progress{margin-top:10px}.list-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.list-card{padding:21px;display:block;text-align:left;transition:transform .2s}.list-card:hover{transform:translateY(-3px)}.list-art{height:117px;display:flex;align-items:center;justify-content:center;background:var(--quote);border-radius:7px;margin-bottom:18px}.list-art .mini-cover:nth-child(2){transform:translateY(-5px);margin:0 -4px;z-index:1}.list-card h3{font-size:15px;margin-bottom:8px}.list-card p{font-size:11px}.list-card>small{display:block;color:var(--muted);font-size:10px;margin-top:15px}.full-books{grid-template-columns:repeat(5,minmax(0,1fr));gap:26px}.full-books .cover{height:168px;width:118px}.row-list{display:flex;flex-direction:column}.book-row{display:flex;align-items:center;gap:15px;padding:14px 0;border-bottom:1px solid var(--line)}.book-row:last-child{border:0}.book-row .mini-cover{width:42px;height:58px;padding:5px}.book-row .mini-cover b{font-size:8px}.book-row .mini-cover small{display:none}.book-row-text{flex:1;min-width:0}.book-row-text b{font-size:12px;font-weight:500}.book-row-text p{font-size:10px;margin-top:4px}.book-row .text-button{color:var(--accent)}.book-row-status{font-size:11px;color:var(--green)}.book-row .progress{max-width:200px}.notebook-grid{display:grid;grid-template-columns:230px minmax(0,1fr);gap:24px}.notebook-menu button{display:block;width:100%;text-align:left;border-radius:8px;padding:16px;margin-bottom:7px;border:1px solid transparent}.notebook-menu button.active{background:var(--soft);border-color:var(--line)}.notebook-menu button b{font-size:12px;font-weight:500}.notebook-menu button small{display:block;font-size:10px;margin-top:6px;color:var(--muted)}.note-content{padding:22px;margin:0 0 15px;background:var(--panel);border:1px solid var(--line);border-radius:10px}.note-content blockquote{margin:15px 0;font-family:'Songti SC',serif;font-size:16px;line-height:1.9}.note-content small{color:var(--muted);font-size:10px}.note-original{background:var(--quote);border-left:2px solid #b6a475;padding:12px;font-size:12px;line-height:1.9;margin-top:12px}.detail-tabs{display:flex;gap:19px;border-bottom:1px solid var(--line);margin:25px 0 18px;overflow:auto}.detail-tabs button{white-space:nowrap;font-size:12px;color:var(--muted);padding:0 0 12px;border-bottom:2px solid transparent}.detail-tabs button.active{color:var(--accent);border-color:var(--accent)}#detail{width:760px;max-height:90vh}#goal-editor{width:700px;max-height:90vh}.picker-head{margin:22px 0 10px}.picker-list{max-height:280px;overflow:auto;border:1px solid var(--line);border-radius:8px;margin-top:12px}.pick-row{display:flex;align-items:center;gap:12px;padding:12px 15px;border-bottom:1px solid var(--line);cursor:pointer}.pick-row:last-child{border:0}.pick-row input{accent-color:#356ce5;width:16px;height:16px}.pick-row b{font-size:12px;font-weight:500}.pick-row small{display:block;color:var(--muted);font-size:10px;margin-top:4px}.pick-row .pill{margin-left:auto}.picker-lists{display:flex;gap:7px;flex-wrap:wrap;margin:12px 0}.picker-lists button{border:1px solid var(--line);border-radius:6px;font-size:11px;padding:8px;background:var(--bg)}.picker-lists button.active{background:var(--soft);color:var(--accent)}.editor-footer{display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding-top:20px;margin-top:12px}.editor-footer>span{font-size:10px;color:var(--muted)}.form-error{color:#bd5151;font-size:12px;margin:12px 0}.insight-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin-top:22px}.insight h3{margin-bottom:20px}.insight-line{display:flex;justify-content:space-between;gap:12px;padding:10px 0;color:var(--muted);font-size:11px}.insight-line b{color:var(--text);font-weight:500}.insight .progress{margin-top:5px}.section-space{margin-top:24px}.discovery-hero{padding:27px;background:var(--quote);border-radius:10px;margin-bottom:24px}.discovery-hero h2{font-family:'Songti SC',serif;font-size:22px;font-weight:500;margin:0 0 8px}.status-box{text-align:center;padding:65px 20px}.status-box h3{margin:12px}.status-box button{margin-top:18px}.type-chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px}.type-chips button{padding:8px 11px;font-size:11px;border-radius:7px;border:1px solid var(--line)}.type-chips button.active{background:var(--soft);color:var(--accent);border-color:transparent}.review-row{border-bottom:1px solid var(--line);padding:18px 0}.review-row p{font-size:13px;color:var(--text);margin-top:12px}.review-row .meta{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted)}.detail-subtitle{margin:15px 0}.book-details-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:12px}.book-details-grid span{color:var(--muted)}.chapter-row{padding:14px 0;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;font-size:12px}.media-card{display:flex;gap:18px;align-items:center;padding:24px}.media-icon{width:65px;height:65px;flex-shrink:0;background:var(--soft);color:var(--accent);border-radius:14px;display:grid;place-items:center;font-size:27px}.hour-bars{height:58px;display:flex;align-items:end;gap:3px}.hour-bars i{height:var(--height);flex:1;background:#84a4d8;border-radius:2px 2px 0 0}.insight-detail{margin-top:20px}.insight-detail summary{cursor:pointer;font-size:12px;color:var(--accent);padding:15px 0}.source-tag{font-size:10px;color:var(--muted);margin:8px 0}.goal-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:22px}.detail-empty{padding:35px;color:var(--muted);font-size:12px;text-align:center}.cover .title{flex-shrink:0}.cover .tiny{font-size:5px}.c1 .title,.c3 .title{font-size:17px}.cover .author{font-size:6px}.cover .publisher{font-size:5px}.trend-total small{display:inline-block;font-size:11px}.trend{margin-bottom:0}.shelf{margin-top:22px}.list-index{color:var(--muted);font-size:10px;margin-right:5px}.summary-goals{margin-bottom:0} -@media(max-width:1200px){.full-books{grid-template-columns:repeat(4,minmax(0,1fr))}.list-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:900px){.goals-grid{grid-template-columns:1fr}.columns>.rightcol{display:flex}.notebook-grid{grid-template-columns:1fr}.notebook-menu{display:flex;overflow:auto}.notebook-menu button{min-width:160px}.full-books{grid-template-columns:repeat(3,minmax(0,1fr))}.insight-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.sidebar .nav{gap:9px}.welcome{align-items:flex-start;gap:15px}.welcome h1{line-height:1.4}.welcome .primary{white-space:nowrap}.goal-intro{padding:22px}.demo-banner>span:last-child{display:none}}@media(max-width:600px){.full-books{grid-template-columns:repeat(2,minmax(0,1fr))}.list-grid,.insight-grid{grid-template-columns:1fr}.goal-intro{grid-template-columns:1fr}.goal-intro .symbol{display:none}.goal-card{padding:20px}.welcome{flex-direction:column}.welcome .page-actions{align-self:flex-end}.panel{padding:18px}.date-fields{grid-template-columns:1fr;gap:0}.detail-body>.cover{width:95px;flex-shrink:0}.detail-body{gap:15px}.detail-info h3{font-size:18px}.book-details-grid{grid-template-columns:1fr}.toolbar input{width:100%}.metrics-row{gap:16px;flex-wrap:wrap}.metrics-row strong{font-size:23px}.goal-intro h2{font-size:20px}.cover-stack .mini-cover{width:57px}.stack-more{font-size:10px;margin-left:10px}.demo-banner{font-size:9px}.goal-card h3{font-size:17px}dialog{padding:20px}.editor-footer{gap:10px}.book-row{gap:9px}.book-row-text b{font-size:11px}} -.compact-value{font-family:Georgia,'PingFang SC',sans-serif;font-size:25px;line-height:1.5;letter-spacing:-.5px}.stat-foot{line-height:1.7}.insight-detail .insight-grid{margin-top:0}.sidebar .nav button[aria-current=page]{color:var(--accent)} -@media(max-width:600px){.compact-value{font-size:20px}.list-art .mini-cover{width:56px}.full-books .cover{width:105px;height:159px}.goal-card .section-head{gap:10px}.book-row .mini-cover{display:none}.detail-tabs{gap:16px}.date-fields input{min-height:40px}.detail-info .primary{font-size:11px;padding:9px}} -.mini-cover.c2,.mini-cover.c3{color:#fff5df} -.export-preview{width:100%;height:320px;margin-top:20px;border:1px solid var(--line);border-radius:8px;padding:18px;background:var(--bg);color:var(--text);font:12px/1.9 'PingFang SC',sans-serif;resize:vertical} -.real-cover{display:block;width:118px;height:168px;object-fit:cover;border-radius:4px;box-shadow:4px 5px 10px #0002;background:var(--quote)} -.mini-real-cover{width:66px;height:92px;flex-shrink:0} -.book-row .mini-real-cover{width:42px;height:58px} +:root { + --muted: #717b8b +} + +[data-theme=dark] { + --muted: #a2adbf +} + +h3 { + font-size: 14px; + margin: 0 +} + +button:disabled { + opacity: .45; + cursor: not-allowed +} + +.demo-banner { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 10px; + margin-top: 18px +} + +.welcome { + margin-top: 26px +} + +.page-actions { + display: flex; + align-items: center; + gap: 10px +} + +.secondary { + padding: 10px 15px; + border: 1px solid var(--line); + background: var(--panel); + border-radius: 7px; + font-size: 12px +} + +.pill { + display: inline-block; + padding: 5px 8px; + font-size: 10px; + border-radius: 5px; + background: var(--soft); + color: var(--accent); + white-space: nowrap +} + +.pill.green { + background: #6a9a8219; + color: var(--green) +} + +.pill.amber { + background: #c3923919; + color: #b78a3f +} + +.hint { + font-size: 11px; + color: var(--muted); + line-height: 1.8 +} + +.panel { + padding: 24px +} + +.panel+.panel { + margin-top: 20px +} + +.toolbar { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + margin: 0 0 22px +} + +.toolbar .tabs { + margin: 0; + flex-wrap: wrap +} + +.toolbar input, +.toolbar select, +.field input, +select { + border: 1px solid var(--line); + border-radius: 7px; + padding: 10px 12px; + background: var(--panel); + color: var(--text); + font: inherit; + font-size: 12px +} + +.toolbar select { + max-width: 100% +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; + margin-bottom: 18px +} + +.field input { + width: 100%; + background: var(--bg) +} + +.date-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px +} + +.metrics-row { + display: flex; + gap: 24px; + padding: 18px 0; + margin-bottom: 10px; + color: var(--muted); + font-size: 11px +} + +.metrics-row strong { + font-family: Georgia; + font-size: 28px; + color: var(--text); + margin-right: 6px; + font-weight: 400 +} + +.goals-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px +} + +.goal-card { + padding: 25px; + position: relative; + overflow: hidden +} + +.goal-card:before { + content: ''; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--green) +} + +.goal-card h3 { + font-size: 19px; + margin: 17px 0 8px; + word-break: break-word +} + +.goal-card .section-head { + margin-bottom: 0 +} + +.goal-date { + font-size: 11px; + color: var(--muted) +} + +.cover-stack { + display: flex; + gap: 0; + margin: 24px 0 20px; + height: 92px; + padding-left: 4px +} + +.mini-cover { + width: 66px; + height: 92px; + padding: 10px 8px; + border-radius: 2px 4px 4px 2px; + box-shadow: 4px 5px 10px #0002; + display: flex; + flex-direction: column; + flex-shrink: 0; + position: relative; + overflow: hidden; + border-left: 3px solid #0002 +} + +.cover-stack .mini-cover { + margin-left: -4px; + transform: rotate(-3deg); + transform-origin: bottom +} + +.cover-stack .mini-cover:nth-child(2n) { + transform: rotate(4deg) +} + +.mini-cover b { + font-family: 'Songti SC', serif; + font-size: 12px; + position: relative; + z-index: 1; + line-height: 1.5 +} + +.mini-cover small { + font-size: 6px; + position: relative; + margin-top: auto +} + +.stack-more { + align-self: center; + margin-left: 18px; + color: var(--muted); + font-size: 12px +} + +.goal-progress { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + color: var(--muted); + margin: 12px 0 9px +} + +.goal-progress strong { + font-size: 22px; + color: var(--text); + font-family: Georgia; + font-weight: 400 +} + +.goal-card .progress { + height: 5px; + margin: 0 0 19px +} + +.goal-card .progress i { + background: var(--green) +} + +.goal-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid var(--line); + padding-top: 16px +} + +.goal-card-footer small { + font-size: 10px; + color: var(--muted) +} + +.goal-card-footer button { + font-size: 11px; + color: var(--accent) +} + +.goal-intro { + display: grid; + grid-template-columns: 1fr auto; + gap: 28px; + background: var(--quote); + padding: 26px 30px; + margin-bottom: 24px; + border: 1px solid var(--line); + border-radius: 12px +} + +.goal-intro h2 { + font-family: 'Songti SC', serif; + font-size: 23px; + font-weight: 500; + margin: 0 0 10px +} + +.goal-intro p { + max-width: 500px +} + +.goal-intro .symbol { + font-family: Georgia; + font-size: 65px; + align-self: center; + color: #819583 +} + +.goal-summary-row { + display: block; + text-align: left; + width: 100%; + padding: 16px 0; + border-top: 1px solid var(--line) +} + +.goal-summary-row strong { + font-size: 12px; + font-weight: 500 +} + +.goal-summary-row small { + display: block; + margin-top: 7px; + color: var(--muted); + font-size: 10px +} + +.goal-summary-row .progress { + margin-top: 10px +} + +.list-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px +} + +.list-card { + padding: 21px; + display: block; + text-align: left; + transition: transform .2s +} + +.list-card:hover { + transform: translateY(-3px) +} + +.list-art { + height: 117px; + display: flex; + align-items: center; + justify-content: center; + background: var(--quote); + border-radius: 7px; + margin-bottom: 18px +} + +.list-art .mini-cover:nth-child(2) { + transform: translateY(-5px); + margin: 0 -4px; + z-index: 1 +} + +.list-card h3 { + font-size: 15px; + margin-bottom: 8px +} + +.list-card p { + font-size: 11px +} + +.list-card>small { + display: block; + color: var(--muted); + font-size: 10px; + margin-top: 15px +} + +.full-books { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 26px +} + +.full-books .cover { + height: 168px; + width: 118px +} + +.row-list { + display: flex; + flex-direction: column +} + +.book-row { + display: flex; + align-items: center; + gap: 15px; + padding: 14px 0; + border-bottom: 1px solid var(--line) +} + +.book-row:last-child { + border: 0 +} + +.book-row .mini-cover { + width: 42px; + height: 58px; + padding: 5px +} + +.book-row .mini-cover b { + font-size: 8px +} + +.book-row .mini-cover small { + display: none +} + +.book-row-text { + flex: 1; + min-width: 0 +} + +.book-row-text b { + font-size: 12px; + font-weight: 500 +} + +.book-row-text p { + font-size: 10px; + margin-top: 4px +} + +.book-row .text-button { + color: var(--accent) +} + +.book-row-status { + font-size: 11px; + color: var(--green) +} + +.book-row .progress { + max-width: 200px +} + +.notebook-grid { + display: grid; + grid-template-columns: 230px minmax(0, 1fr); + gap: 24px +} + +.notebook-menu button { + display: block; + width: 100%; + text-align: left; + border-radius: 8px; + padding: 16px; + margin-bottom: 7px; + border: 1px solid transparent +} + +.notebook-menu button.active { + background: var(--soft); + border-color: var(--line) +} + +.notebook-menu button b { + font-size: 12px; + font-weight: 500 +} + +.notebook-menu button small { + display: block; + font-size: 10px; + margin-top: 6px; + color: var(--muted) +} + +.note-content { + padding: 22px; + margin: 0 0 15px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px +} + +.note-content blockquote { + margin: 15px 0; + font-family: 'Songti SC', serif; + font-size: 16px; + line-height: 1.9 +} + +.note-content small { + color: var(--muted); + font-size: 10px +} + +.note-original { + background: var(--quote); + border-left: 2px solid #b6a475; + padding: 12px; + font-size: 12px; + line-height: 1.9; + margin-top: 12px +} + +.detail-tabs { + display: flex; + gap: 19px; + border-bottom: 1px solid var(--line); + margin: 25px 0 18px; + overflow: auto +} + +.detail-tabs button { + white-space: nowrap; + font-size: 12px; + color: var(--muted); + padding: 0 0 12px; + border-bottom: 2px solid transparent +} + +.detail-tabs button.active { + color: var(--accent); + border-color: var(--accent) +} + +#detail { + width: 760px; + max-height: 90vh +} + +#goal-editor { + width: 700px; + max-height: 90vh +} + +.picker-head { + margin: 22px 0 10px +} + +.picker-list { + max-height: 280px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + margin-top: 12px +} + +.pick-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 15px; + border-bottom: 1px solid var(--line); + cursor: pointer +} + +.pick-row:last-child { + border: 0 +} + +.pick-row input { + accent-color: #356ce5; + width: 16px; + height: 16px +} + +.pick-row b { + font-size: 12px; + font-weight: 500 +} + +.pick-row small { + display: block; + color: var(--muted); + font-size: 10px; + margin-top: 4px +} + +.pick-row .pill { + margin-left: auto +} + +.picker-lists { + display: flex; + gap: 7px; + flex-wrap: wrap; + margin: 12px 0 +} + +.picker-lists button { + border: 1px solid var(--line); + border-radius: 6px; + font-size: 11px; + padding: 8px; + background: var(--bg) +} + +.picker-lists button.active { + background: var(--soft); + color: var(--accent) +} + +.editor-footer { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--line); + padding-top: 20px; + margin-top: 12px +} + +.editor-footer>span { + font-size: 10px; + color: var(--muted) +} + +.form-error { + color: #bd5151; + font-size: 12px; + margin: 12px 0 +} + +.insight-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + margin-top: 22px +} + +.insight h3 { + margin-bottom: 20px +} + +.insight-line { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + color: var(--muted); + font-size: 11px +} + +.insight-line b { + color: var(--text); + font-weight: 500 +} + +.insight .progress { + margin-top: 5px +} + +.section-space { + margin-top: 24px +} + +.discovery-hero { + padding: 27px; + background: var(--quote); + border-radius: 10px; + margin-bottom: 24px +} + +.discovery-hero h2 { + font-family: 'Songti SC', serif; + font-size: 22px; + font-weight: 500; + margin: 0 0 8px +} + +.status-box { + text-align: center; + padding: 65px 20px +} + +.status-box h3 { + margin: 12px +} + +.status-box button { + margin-top: 18px +} + +.type-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 20px +} + +.type-chips button { + padding: 8px 11px; + font-size: 11px; + border-radius: 7px; + border: 1px solid var(--line) +} + +.type-chips button.active { + background: var(--soft); + color: var(--accent); + border-color: transparent +} + +.review-row { + border-bottom: 1px solid var(--line); + padding: 18px 0 +} + +.review-row p { + font-size: 13px; + color: var(--text); + margin-top: 12px +} + +.review-row .meta { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + color: var(--muted) +} + +.detail-subtitle { + margin: 15px 0 +} + +.book-details-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + font-size: 12px +} + +.book-details-grid span { + color: var(--muted) +} + +.chapter-row { + padding: 14px 0; + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + font-size: 12px +} + +.media-card { + display: flex; + gap: 18px; + align-items: center; + padding: 24px +} + +.media-icon { + width: 65px; + height: 65px; + flex-shrink: 0; + background: var(--soft); + color: var(--accent); + border-radius: 14px; + display: grid; + place-items: center; + font-size: 27px +} + +.hour-bars { + height: 58px; + display: flex; + align-items: end; + gap: 3px +} + +.hour-bars i { + height: var(--height); + flex: 1; + background: #84a4d8; + border-radius: 2px 2px 0 0 +} + +.insight-detail { + margin-top: 20px +} + +.insight-detail summary { + cursor: pointer; + font-size: 12px; + color: var(--accent); + padding: 15px 0 +} + +.source-tag { + font-size: 10px; + color: var(--muted); + margin: 8px 0 +} + +.goal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 22px +} + +.detail-empty { + padding: 35px; + color: var(--muted); + font-size: 12px; + text-align: center +} + +.cover .title { + flex-shrink: 0 +} + +.cover .tiny { + font-size: 5px +} + +.c1 .title, +.c3 .title { + font-size: 17px +} + +.cover .author { + font-size: 6px +} + +.cover .publisher { + font-size: 5px +} + +.trend-total small { + display: inline-block; + font-size: 11px +} + +.trend { + margin-bottom: 0 +} + +.shelf { + margin-top: 22px +} + +.list-index { + color: var(--muted); + font-size: 10px; + margin-right: 5px +} + +.summary-goals { + margin-bottom: 0 +} + +@media(max-width:1200px) { + .full-books { + grid-template-columns: repeat(4, minmax(0, 1fr)) + } + + .list-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .insight-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } +} + +@media(max-width:900px) { + .goals-grid { + grid-template-columns: 1fr + } + + .columns>.rightcol { + display: flex + } + + .notebook-grid { + grid-template-columns: 1fr + } + + .notebook-menu { + display: flex; + overflow: auto + } + + .notebook-menu button { + min-width: 160px + } + + .full-books { + grid-template-columns: repeat(3, minmax(0, 1fr)) + } + + .insight-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .sidebar .nav { + gap: 9px + } + + .welcome { + align-items: flex-start; + gap: 15px + } + + .welcome h1 { + line-height: 1.4 + } + + .welcome .primary { + white-space: nowrap + } + + .goal-intro { + padding: 22px + } + + .demo-banner>span:last-child { + display: none + } +} + +@media(max-width:600px) { + .full-books { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .list-grid, + .insight-grid { + grid-template-columns: 1fr + } + + .goal-intro { + grid-template-columns: 1fr + } + + .goal-intro .symbol { + display: none + } + + .goal-card { + padding: 20px + } + + .welcome { + flex-direction: column + } + + .welcome .page-actions { + align-self: flex-end + } + + .panel { + padding: 18px + } + + .date-fields { + grid-template-columns: 1fr; + gap: 0 + } + + .detail-body>.cover { + width: 95px; + flex-shrink: 0 + } + + .detail-body { + gap: 15px + } + + .detail-info h3 { + font-size: 18px + } + + .book-details-grid { + grid-template-columns: 1fr + } + + .toolbar input { + width: 100% + } + + .metrics-row { + gap: 16px; + flex-wrap: wrap + } + + .metrics-row strong { + font-size: 23px + } + + .goal-intro h2 { + font-size: 20px + } + + .cover-stack .mini-cover { + width: 57px + } + + .stack-more { + font-size: 10px; + margin-left: 10px + } + + .demo-banner { + font-size: 9px + } + + .goal-card h3 { + font-size: 17px + } + + dialog { + padding: 20px + } + + .editor-footer { + gap: 10px + } + + .book-row { + gap: 9px + } + + .book-row-text b { + font-size: 11px + } +} + +.compact-value { + font-family: Georgia, 'PingFang SC', sans-serif; + font-size: 25px; + line-height: 1.5; + letter-spacing: -.5px +} + +.stat-foot { + line-height: 1.7 +} + +.insight-detail .insight-grid { + margin-top: 0 +} + +.sidebar .nav button[aria-current=page] { + color: var(--accent) +} + +@media(max-width:600px) { + .compact-value { + font-size: 20px + } + + .list-art .mini-cover { + width: 56px + } + + .full-books .cover { + width: 105px; + height: 159px + } + + .goal-card .section-head { + gap: 10px + } + + .book-row .mini-cover { + display: none + } + + .detail-tabs { + gap: 16px + } + + .date-fields input { + min-height: 40px + } + + .detail-info .primary { + font-size: 11px; + padding: 9px + } +} + +.mini-cover.c2, +.mini-cover.c3 { + color: #fff5df +} + +.export-preview { + width: 100%; + height: 320px; + margin-top: 20px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 18px; + background: var(--bg); + color: var(--text); + font: 12px/1.9 'PingFang SC', sans-serif; + resize: vertical +} + +.real-cover { + display: block; + width: 118px; + height: 168px; + object-fit: cover; + border-radius: 4px; + box-shadow: 4px 5px 10px #0002; + background: var(--quote) +} + +.mini-real-cover { + width: 66px; + height: 92px; + flex-shrink: 0 +} + +.book-row .mini-real-cover { + width: 42px; + height: 58px +} + /* 紧凑内容布局:压缩页面介绍,保留正文、书封和操作的可读尺寸。 */ -.topbar{height:58px} -.top-actions{gap:16px} -.demo-banner{margin-top:10px;gap:16px;line-height:1.5} -.welcome{margin:8px 0 10px;gap:14px;align-items:center} -.welcome h1{font-size:23px;line-height:1.35;margin-bottom:3px} -.welcome p{line-height:1.55} -.welcome .eyebrow,.goal-intro .eyebrow,.goal-intro .symbol{display:none} -.page-actions{flex-shrink:0} -.page-actions:empty{display:none} -.toolbar{margin-bottom:10px;gap:8px 16px} -#page-content>.type-chips{margin:0 0 10px;gap:8px} -.toolbar .type-chips{margin:0} -#page-content>.type-chips button,.toolbar .type-chips button{min-height:36px;padding:8px 12px} -.stats{gap:14px;margin-bottom:14px} -.stat{padding:14px 18px;min-width:0} -.stat-value{margin:8px 0} -.compact-value{font-size:24px;overflow-wrap:anywhere} -.columns,.rightcol{gap:16px} -.shelf,.trend,.summary-goals{padding:18px} -.panel{padding:18px} -.section-head{margin-bottom:14px;gap:12px} -.shelf{margin-top:16px} -.books{gap:18px} -.full-books{grid-template-columns:repeat(auto-fill,minmax(116px,1fr));gap:22px 18px} -.book .real-cover{max-width:100%;height:auto;aspect-ratio:118/168} -.book h3{margin:10px 0 4px} -.book p{line-height:1.45} -.book .progress{margin-top:9px} -.summary-goals .status-box{padding:18px 10px;text-align:left} -.summary-goals .status-box h3{margin:6px 0;font-size:13px} -.summary-goals .status-box button{margin-top:10px} -.goal-intro{display:block;padding:12px 16px;margin-bottom:14px} -.goal-intro>div{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:20px;align-items:center} -.goal-intro h2{font-size:16px;margin:0 0 2px} -.goal-intro p{font-size:10px;line-height:1.6} -.goal-intro .metrics-row{grid-column:2;grid-row:1/3;margin:0;padding:0;gap:16px;white-space:nowrap} -.goal-intro .metrics-row span{display:flex;flex-direction:column;gap:2px} -.goal-intro .metrics-row strong{font-size:20px;line-height:1.2;margin:0} -.goals-grid{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px;align-items:start} -.goal-card{padding:18px} -.goal-card h3{font-size:17px;margin:10px 0 5px} -.goal-card .cover-stack{margin:14px 0;height:66px} -.goal-card .mini-cover,.goal-card .mini-real-cover{width:47px;height:66px} -.goal-card .progress{margin-bottom:12px} -.goal-card-footer{padding-top:12px;gap:12px;flex-wrap:wrap} -.goal-card-footer button{min-height:32px} -.goal-summary-row{padding:12px 0} -@media(max-width:1500px){.full-books{grid-template-columns:repeat(auto-fill,minmax(116px,1fr));gap:20px 16px}} -@media(max-width:1200px){.full-books{grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:18px 14px}} -@media(max-width:900px){.full-books{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:16px 12px}} -@media(max-width:1150px){ - .goal-intro>div{grid-template-columns:1fr;gap:4px} - .goal-intro .metrics-row{grid-column:auto;grid-row:auto;margin-top:8px;flex-wrap:wrap} - .goal-intro .metrics-row span{display:block} - .goal-intro .metrics-row strong{margin-right:6px} +.topbar { + height: 58px } -@media(max-width:600px){ - .topbar{height:56px} - .demo-banner{margin-top:8px} - .welcome{align-items:flex-start;gap:12px} - .welcome h1{font-size:20px} - .welcome p{font-size:11px} - .welcome .page-actions{align-self:flex-start} - .primary,.secondary,.iconbtn{min-height:40px} - .stats{gap:12px} - .stat{padding:12px} - .compact-value{font-size:20px} - .shelf,.trend,.summary-goals,.goal-card,.panel{padding:14px} - .full-books{grid-template-columns:repeat(2,minmax(0,1fr));gap:18px 12px} - .goal-intro{padding:16px} - .goal-intro .metrics-row{gap:12px} - .goal-intro .metrics-row strong{font-size:22px} - .goal-card .section-head{flex-wrap:wrap} - .goal-card .cover-stack{gap:0} - .goal-card .mini-cover,.goal-card .mini-real-cover{width:44px;height:62px} - .goal-card .cover-stack{height:62px} - .stack-more{margin-left:8px;white-space:nowrap} + +.top-actions { + gap: 16px } -.login-page{min-height:100vh;display:grid;place-items:center;padding:24px}.login-shell{width:min(100%,420px)}.login-logo{justify-content:center;margin-bottom:26px}.login-logo .logo-mark{border-radius:9px}.login-logo svg{width:24px;height:24px}.login-panel{padding:30px}.login-panel h1{font-size:23px;margin:0 0 8px}.login-panel>p{margin-bottom:23px}.login-form{display:grid;gap:16px;margin-top:22px}.login-form label{display:grid;gap:7px;font-size:12px;color:var(--text)}.login-form input{width:100%;min-height:42px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);padding:10px 12px}.login-form input:focus{border-color:var(--accent)}.login-form .primary{width:100%;min-height:42px;margin-top:4px}.login-error{margin-top:16px;padding:10px 12px;border:1px solid #e6b9b5;border-radius:7px;background:#fff5f4;color:#a8443c;font-size:12px;line-height:1.6}.login-foot{text-align:center;margin-top:20px}.top-actions form{margin:0}.logout-button{white-space:nowrap}@media(max-width:600px){.login-page{padding:18px}.login-panel{padding:24px}.top-actions{gap:9px}.logout-button{font-size:10px}} + +.demo-banner { + margin-top: 10px; + gap: 16px; + line-height: 1.5 +} + +.welcome { + margin: 8px 0 10px; + gap: 14px; + align-items: center +} + +.welcome h1 { + font-size: 23px; + line-height: 1.35; + margin-bottom: 3px +} + +.welcome p { + line-height: 1.55 +} + +.welcome .eyebrow, +.goal-intro .eyebrow, +.goal-intro .symbol { + display: none +} + +.page-actions { + flex-shrink: 0 +} + +.page-actions:empty { + display: none +} + +.toolbar { + margin-bottom: 10px; + gap: 8px 16px +} + +#page-content>.type-chips { + margin: 0 0 10px; + gap: 8px +} + +.toolbar .type-chips { + margin: 0 +} + +#page-content>.type-chips button, +.toolbar .type-chips button { + min-height: 36px; + padding: 8px 12px +} + +.stats { + gap: 14px; + margin-bottom: 14px +} + +.stat { + padding: 14px 18px; + min-width: 0 +} + +.stat-value { + margin: 8px 0 +} + +.compact-value { + font-size: 24px; + overflow-wrap: anywhere +} + +.columns, +.rightcol { + gap: 16px +} + +.shelf, +.trend, +.summary-goals { + padding: 18px +} + +.panel { + padding: 18px +} + +.section-head { + margin-bottom: 14px; + gap: 12px +} + +.shelf { + margin-top: 16px +} + +.books { + gap: 18px +} + +.full-books { + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 22px 18px +} + +.book .real-cover { + max-width: 100%; + height: auto; + aspect-ratio: 118/168 +} + +.book h3 { + margin: 10px 0 4px +} + +.book p { + line-height: 1.45 +} + +.book .progress { + margin-top: 9px +} + +.summary-goals .status-box { + padding: 18px 10px; + text-align: left +} + +.summary-goals .status-box h3 { + margin: 6px 0; + font-size: 13px +} + +.summary-goals .status-box button { + margin-top: 10px +} + +.goal-intro { + display: block; + padding: 12px 16px; + margin-bottom: 14px +} + +.goal-intro>div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 20px; + align-items: center +} + +.goal-intro h2 { + font-size: 16px; + margin: 0 0 2px +} + +.goal-intro p { + font-size: 10px; + line-height: 1.6 +} + +.goal-intro .metrics-row { + grid-column: 2; + grid-row: 1/3; + margin: 0; + padding: 0; + gap: 16px; + white-space: nowrap +} + +.goal-intro .metrics-row span { + display: flex; + flex-direction: column; + gap: 2px +} + +.goal-intro .metrics-row strong { + font-size: 20px; + line-height: 1.2; + margin: 0 +} + +.goals-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + align-items: start +} + +.goal-card { + padding: 18px +} + +.goal-card h3 { + font-size: 17px; + margin: 10px 0 5px +} + +.goal-card .cover-stack { + margin: 14px 0; + height: 66px +} + +.goal-card .mini-cover, +.goal-card .mini-real-cover { + width: 47px; + height: 66px +} + +.goal-card .progress { + margin-bottom: 12px +} + +.goal-card-footer { + padding-top: 12px; + gap: 12px; + flex-wrap: wrap +} + +.goal-card-footer button { + min-height: 32px +} + +.goal-summary-row { + padding: 12px 0 +} + +@media(max-width:1500px) { + .full-books { + grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); + gap: 20px 16px + } +} + +@media(max-width:1200px) { + .full-books { + grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); + gap: 18px 14px + } +} + +@media(max-width:900px) { + .full-books { + grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); + gap: 16px 12px + } +} + +@media(max-width:1150px) { + .goal-intro>div { + grid-template-columns: 1fr; + gap: 4px + } + + .goal-intro .metrics-row { + grid-column: auto; + grid-row: auto; + margin-top: 8px; + flex-wrap: wrap + } + + .goal-intro .metrics-row span { + display: block + } + + .goal-intro .metrics-row strong { + margin-right: 6px + } +} + +@media(max-width:600px) { + .topbar { + height: 56px + } + + .demo-banner { + margin-top: 8px + } + + .welcome { + align-items: flex-start; + gap: 12px + } + + .welcome h1 { + font-size: 20px + } + + .welcome p { + font-size: 11px + } + + .welcome .page-actions { + align-self: flex-start + } + + .primary, + .secondary, + .iconbtn { + min-height: 40px + } + + .stats { + gap: 12px + } + + .stat { + padding: 12px + } + + .compact-value { + font-size: 20px + } + + .shelf, + .trend, + .summary-goals, + .goal-card, + .panel { + padding: 14px + } + + .full-books { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px 12px + } + + .goal-intro { + padding: 16px + } + + .goal-intro .metrics-row { + gap: 12px + } + + .goal-intro .metrics-row strong { + font-size: 22px + } + + .goal-card .section-head { + flex-wrap: wrap + } + + .goal-card .cover-stack { + gap: 0 + } + + .goal-card .mini-cover, + .goal-card .mini-real-cover { + width: 44px; + height: 62px + } + + .goal-card .cover-stack { + height: 62px + } + + .stack-more { + margin-left: 8px; + white-space: nowrap + } +} + +.login-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px +} + +.login-shell { + width: min(100%, 420px) +} + +.login-logo { + justify-content: center; + margin-bottom: 26px +} + +.login-logo .logo-mark { + border-radius: 9px +} + +.login-logo svg { + width: 24px; + height: 24px +} + +.login-panel { + padding: 30px +} + +.login-panel h1 { + font-size: 23px; + margin: 0 0 8px +} + +.login-panel>p { + margin-bottom: 23px +} + +.login-form { + display: grid; + gap: 16px; + margin-top: 22px +} + +.login-form label { + display: grid; + gap: 7px; + font-size: 12px; + color: var(--text) +} + +.login-form input { + width: 100%; + min-height: 42px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--bg); + color: var(--text); + padding: 10px 12px +} + +.login-form input:focus { + border-color: var(--accent) +} + +.login-form .primary { + width: 100%; + min-height: 42px; + margin-top: 4px +} + +.login-error { + margin-top: 16px; + padding: 10px 12px; + border: 1px solid #e6b9b5; + border-radius: 7px; + background: #fff5f4; + color: #a8443c; + font-size: 12px; + line-height: 1.6 +} + +.login-foot { + text-align: center; + margin-top: 20px +} + +.top-actions form { + margin: 0 +} + +.logout-button { + white-space: nowrap +} + +@media(max-width:600px) { + .login-page { + padding: 18px + } + + .login-panel { + padding: 24px + } + + .top-actions { + gap: 9px + } + + .logout-button { + font-size: 10px + } +} + + +/* 书架分页与排序 */ +.shelf-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0 0 14px; + flex-wrap: wrap +} + +.shelf-sort-control { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + color: var(--muted); + font-size: 11px +} + +.shelf-sort-control select { + min-width: 140px +} + +.shelf-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + margin-top: 18px; + flex-wrap: wrap +} + +.shelf-pagination .page-btn { + min-width: 36px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--panel); + color: var(--text); + font: inherit; + font-size: 11px; + cursor: pointer +} + +.shelf-pagination .page-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent) +} + +.shelf-pagination .page-btn:disabled { + opacity: .45; + cursor: not-allowed +} + +.shelf-pagination .page-btn.active { + border-color: var(--accent); + background: var(--accent); + color: #fff +} + +@media(max-width:600px) { + .shelf-toolbar { + align-items: flex-start + } + + .shelf-pagination { + gap: 4px + } + + .shelf-pagination .page-btn { + min-width: 32px; + padding: 7px 8px + } +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index 58c48e8..a66f058 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -24,7 +24,7 @@ let searchComposing = false, discoverSearchTimer = null; let recommendOffset = 0; let noteBook = 'all', noteType = 'all', goalFilter = 'all'; 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 toastTimer, storageWarning = '', goalBusy = false; @@ -332,6 +332,50 @@ function renderLists(lists) { return `
${lists.map(listCard).join('') || '
没有匹配的书单。
'}
`; } +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 = ``; + return html; +} + function renderShelf() { if (loadError.shelf) return statusBox('error', '暂时无法读取书架', loadError.shelf, 'retry-shelf'); if (!shelfData) { ensureShelf(); return statusBox('loading', '正在同步书架…', '读取微信读书书架与阅读进度。'); } @@ -348,8 +392,20 @@ function renderShelf() { } else if (shelfTab === 'articles') { content = `
▤

${esc((shelfData.mp && shelfData.mp.title) || '文章收藏')}

这是书架中的收藏入口,共 ${counts.mp ? 1 : 0} 个。

书架接口不提供入口内文章列表;可在发现页按「文章 / 公众号」检索。

`; } else { - const shown = filtered.slice(0, shelfLimit); - content = `
${shown.map(bookCard).join('') || '
这里还没有对应的书。
'}
${filtered.length > shown.length ? `` : ''}
`; + const sorted = [...filtered].sort(compareShelfBooks); + 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]) => ``).join(''); + content = `
${pageSummary}
${shown.map(bookCard).join('') || '
这里还没有对应的书。
'}
${renderShelfPagination(currentPage, totalPages)}`; } 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('loading', '正在读取笔记内容…', '划线、想法与书签数量。')) : `
${counts.highlights + counts.thoughts + counts.bookmarks}条笔记划线 ${counts.highlights}想法 ${counts.thoughts}书签 ${counts.bookmarks}
` + - tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') + - (noteType === 'bookmark' - ? `
${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。
` - : (noteSlice.map(noteCard).join('') || '
当前筛选没有笔记。
')) + - (noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `` : '') + - `

${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。

`; + tabs([['all', '全部内容'], ['highlight', '划线'], ['thought', '想法 / 个人点评'], ['bookmark', '书签数量']], noteType, 'note-type') + + (noteType === 'bookmark' + ? `
${counts.bookmarks} 个书签 · 微信读书接口只提供书签数量,不提供书签内容。
` + : (noteSlice.map(noteCard).join('') || '
当前筛选没有笔记。
')) + + (noteType !== 'bookmark' && shownNotes.length > noteSlice.length ? `` : '') + + `

${noteBook === 'all' ? `「全部笔记本」聚合了笔记最多的 ${payload.aggregated_books || 0} 本,切换左侧笔记本可查看单本全部内容。` : '内容来自微信读书划线与个人想法。'}总笔记数包含书签;导出包含划线和想法,不含公开点评及书签内容。

`; return welcome('把读过的,留在心里', `${notebooksData.totals.books} 本书留下过笔记,共 ${notebooksData.totals.notes} 条。`, '') + `
${body}
`; @@ -600,7 +656,7 @@ function renderDiscover() { } else { const list = payload.books || []; const local = list.filter(book => !searchQuery || `${book.title}${book.author}${book.category}`.toLowerCase().includes(searchQuery.toLowerCase())); - content = `
${(payload.local ? local.slice(0, shelfLimit) : list).map(searchCard).join('') || '
没有找到相关书籍,试试其他书名或作者。
'}
`; + content = `
${(payload.local ? local.slice(0, SHELF_PAGE) : list).map(searchCard).join('') || '
没有找到相关书籍,试试其他书名或作者。
'}
`; } 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 'append-goal': appendGoal(id); break; case 'new-with-selection': openGoalEditor(null, pendingBooks, pendingSources); break; - case 'shelf-tab': shelfTab = id; shelfLimit = SHELF_PAGE; renderPage(); break; - case 'shelf-more': shelfLimit += SHELF_PAGE; renderPage(); break; + case 'shelf-tab': shelfTab = id; shelfPage = 1; renderPage(); break; + case 'shelf-page': shelfPage = Number(id); renderPage(); break; case 'period': period = id; renderPage(); break; case 'discover-tab': discoverTab = id; renderPage(); 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(); 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(); } }); $('#goal-start').onchange = () => { $('#goal-end').min = $('#goal-start').value; }; diff --git a/static/js/demo-data.js b/static/js/demo-data.js index ea9740a..04dcba6 100644 --- a/static/js/demo-data.js +++ b/static/js/demo-data.js @@ -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}`; }); const booklists = [ - {id:'l1',name:'在故事里,读懂人生',source:'我的书单',description:'从别人的故事里,找到自己生活的回声。',ids:['b3','b4','b6']}, - {id:'l2',name:'心理学,向内走一程',source:'我的书单',description:'理解情绪与关系,也练习温柔地对待自己。',ids:['b0','b2','b5']}, - {id:'l3',name:'重新认识这个世界',source:'公开书单 · 示例',description:'从宇宙到日常,拓宽理解世界的尺度。',ids:['b1','b7','b6']}, - {id:'l4',name:'适合慢慢读的夜晚',source:'公开书单 · 示例',description:'把时间留给文学,让心绪落在书页之间。',ids:['b4','b6','b0']} + { id: 'l1', name: '在故事里,读懂人生', source: '我的书单', description: '从别人的故事里,找到自己生活的回声。', ids: ['b3', 'b4', 'b6'] }, + { id: 'l2', name: '心理学,向内走一程', source: '我的书单', description: '理解情绪与关系,也练习温柔地对待自己。', ids: ['b0', 'b2', 'b5'] }, + { id: 'l3', name: '重新认识这个世界', source: '公开书单 · 示例', description: '从宇宙到日常,拓宽理解世界的尺度。', ids: ['b1', 'b7', 'b6'] }, + { id: 'l4', name: '适合慢慢读的夜晚', source: '公开书单 · 示例', description: '把时间留给文学,让心绪落在书页之间。', ids: ['b4', 'b6', 'b0'] } ]; const initialGoals = [ - {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:'g3',name:'读完的书,成为自己的底气',start:'2026-08-01',end:'2026-08-31',ids:['b4','b5'],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: 'g3', name: '读完的书,成为自己的底气', start: '2026-08-01', end: '2026-08-31', ids: ['b4', 'b5'], sources: [] } ]; const personalNotes = [ - {id:'n1',bookId:'b0',type:'thought',text:'把注意力还给生活本身,让平凡的日子有迹可循。',date:'2026-09-11'}, - {id:'n2',bookId:'b1',type:'highlight',text:'在辽阔的未知面前,每一个问题都值得被认真对待。',date:'2026-09-10'}, - {id:'n3',bookId:'b2',type:'thought',text:'允许情绪出现,也给自己一些理解它的时间。',date:'2026-09-08'}, - {id:'n4',bookId:'b3',type:'highlight',text:'人生的答案,或许藏在继续向前的日子里。',date:'2026-09-06'} + { id: 'n1', bookId: 'b0', type: 'thought', text: '把注意力还给生活本身,让平凡的日子有迹可循。', date: '2026-09-11' }, + { id: 'n2', bookId: 'b1', type: 'highlight', text: '在辽阔的未知面前,每一个问题都值得被认真对待。', date: '2026-09-10' }, + { id: 'n3', bookId: 'b2', type: 'thought', text: '允许情绪出现,也给自己一些理解它的时间。', date: '2026-09-08' }, + { id: 'n4', bookId: 'b3', type: 'highlight', text: '人生的答案,或许藏在继续向前的日子里。', date: '2026-09-06' } ]; const publicReviews = [ - {id:'r1',name:'山间来信',rating:5,type:'推荐',text:'适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。',date:'2026-09-10'}, - {id:'r2',name:'晚风书屋',rating:3,type:'一般',text:'有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。',date:'2026-09-09'}, - {id:'r3',name:'一页一页',rating:1,type:'不行',text:'这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。',date:'2026-09-08'} + { id: 'r1', name: '山间来信', rating: 5, type: '推荐', text: '适合放慢速度去读。最打动我的,是读完之后还会不断回想的那些问题。', date: '2026-09-10' }, + { id: 'r2', name: '晚风书屋', rating: 3, type: '一般', text: '有一些有趣的视角,也有需要耐心消化的地方。每个人会读出不同的感受。', date: '2026-09-09' }, + { id: 'r3', name: '一页一页', rating: 1, type: '不行', text: '这次阅读没有找到共鸣,可能还不是适合我当下阅读的书。', date: '2026-09-08' } ]; diff --git a/templates/index.html b/templates/index.html index adb62b6..00311f3 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,20 +1,165 @@ + - -阅间 · 微信读书看板 - - + + + + 阅间 · 微信读书看板 + + + - - -
L
正在连接微信读书…阅读目标跨浏览器保存
-

书籍详情

-
MAKE ROOM FOR READING

新建阅读目标

想读些什么?

选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。

- + + +
+
+ +
+
L
+
+
+
+
+
正在连接微信读书…阅读目标跨浏览器保存
+
+ +
+ +
+

书籍详情

+
+
+
+ +
+
+
+
MAKE ROOM FOR READING
+

新建阅读目标

+
+
+
+
+

想读些什么?

+
+

选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。

+
+ + +
+
+ + + + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index 445ac0f..f25f288 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,39 +1,49 @@ + - - - -登录 · 阅间 - + + + + 登录 · 阅间 + + -
- -
-
YOUR READING SPACE
-

{{ '服务暂不可用' if unavailable else ('首次设置' if not initialized else '欢迎回来') }}

-

{{ '请检查服务配置或数据库连接。' if unavailable else ('创建管理员账号以启用访问保护。' if not initialized else '登录后继续你的阅读旅程。') }}

- {% if error %}{% endif %} - {% if not unavailable and not initialized %} - {% if not setup_enabled %}{% endif %} - - {% elif not unavailable %} - - {% endif %} -
-

阅间 · 与好书相伴,与自己相遇

-
+
+ +
+
YOUR READING SPACE
+

{{ '服务暂不可用' if unavailable else ('首次设置' if not initialized else '欢迎回来') }}

+

{{ '请检查服务配置或数据库连接。' if unavailable else ('创建管理员账号以启用访问保护。' if not initialized else '登录后继续你的阅读旅程。') }}

+ {% if error %}{% endif %} + {% if not unavailable and not initialized %} + {% if not setup_enabled %}{% endif %} + + {% elif not unavailable %} + + {% endif %} +
+

阅间 · 与好书相伴,与自己相遇

+
- + + \ No newline at end of file diff --git a/tests/deploy_master_only.test.sh b/tests/deploy_master_only.test.sh index ea6e9a1..2dcc7fe 100755 --- a/tests/deploy_master_only.test.sh +++ b/tests/deploy_master_only.test.sh @@ -8,16 +8,16 @@ if ! grep -q '^ - master$' "$WORKFLOW"; then echo "部署工作流尚未监听 master" exit 1 fi -if grep -q '^ - dev$' "$WORKFLOW"; then - echo "部署工作流仍监听 dev" +if ! grep -q 'deploy_webhook.py' "$ROOT_DIR/deploy/weread-webhook.service"; then + echo "Webhook systemd 服务未配置" exit 1 fi -if ! grep -q '^ group: deploy-master$' "$WORKFLOW"; then - echo "部署并发组尚未切换到 master" +if ! grep -q 'DEFAULT_BRANCH = "master"' "$ROOT_DIR/deploy_webhook.py"; then + echo "Webhook 未固定 master 分支" exit 1 fi -if ! grep -q '^ DEPLOY_BRANCH: master$' "$WORKFLOW"; then - echo "手动部署分支尚未固定为 master" +if grep -q 'SERVER_SSH_KEY\|ssh -i' "$WORKFLOW"; then + echo "验证工作流仍包含服务器 SSH 部署" exit 1 fi diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..22a0149 --- /dev/null +++ b/tests/test_webhook.py @@ -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()