chore(release): 合并 dev 至 1.1

This commit is contained in:
admin committed 2026-09-12 17:33:28 +08:00
commit 4d706c97a1
44 files changed
+8766 -1492

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
name: Deploy WeReadBoard
on:
push:
branches:
- dev
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: deploy-dev
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: ${{ github.ref_name }}
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"
+5
View File
@@ -29,3 +29,8 @@ Thumbs.db
# Env # Env
.env .env
.server.pid .server.pid
# WorkBuddy workspace memory
# Tool artifacts
.qoder/
.workbuddy/
+31 -7
View File
@@ -1,20 +1,29 @@
# 微信读书数据展示网站 # 微信读书数据展示网站
基于微信读书 API 的个人读书数据展示网站,数据存储在本地 SQLite 数据库中。 基于微信读书 API 的个人读书数据展示网站。
- **v2「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/v2/*` 实时聚合接口,见 `docs/V2_API.md`。
- **v1 仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/v1/`。
## 项目结构 ## 项目结构
``` ```
. .
├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite) ├── fetch_data.py # 数据采集脚本(从微信读书 API 拉取数据到 SQLite)
├── app.py # Flask 后端服务 ├── app.py # Flask 后端服务(v1 接口 + 注册 v2 Blueprint)
├── api_v2.py # v2 数据接口层(/api/v2/*,供「阅间」前端使用)
├── start_dashboard.sh # 启动脚本(端口可用 PORT 覆盖)
├── legacy/v1/ # v1 前端备份(index.html / style.css / app.js)
├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成) ├── weread.db # SQLite 数据库(运行 fetch_data.py 后生成)
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
├── templates/ ├── templates/
│ └── index.html # 前端页面 │ └── index.html # 「阅间」前端页面(v2 设计稿)
└── static/ └── static/
├── css/style.css # 样式文件 ├── css/styles.css # v2 样式(设计稿原样)
└── js/app.js # 前端逻辑 ├── css/style.css # v1 样式(保留)
└── js/
├── weapi.js # v2 数据层(封装 /api/v2/*)
└── app.js # v2 交互逻辑
``` ```
## 快速开始 ## 快速开始
@@ -42,10 +51,25 @@ python fetch_data.py
### 4. 启动网站 ### 4. 启动网站
```bash ```bash
python app.py ./start_dashboard.sh # 默认 8770 端口
PORT=8080 ./start_dashboard.sh # 或指定端口
``` ```
访问 http://127.0.0.1:8080 即可查看读书数据展示。 也可以直接 `python app.py`(默认 8080,可用环境变量 `PORT` 覆盖),
然后访问 http://127.0.0.1:8770 查看「阅间」看板。
> v2 的书架、统计、推荐、搜索等数据实时来自微信读书接口;
> 未运行 `fetch_data.py` 时也能使用,只有进度/笔记等局部数据会缺少缓存加速。
## 自动部署
服务器部署使用根目录的 `deploy.sh`:
```bash
DEPLOY_BRANCH=dev ./deploy.sh
```
脚本会拉取指定分支,自动创建 `.venv`,安装 `requirements.txt`,执行数据库迁移,然后重启 `weread.service`。GitHub Actions 配置在 `.github/workflows/deploy.yml`,默认监听 `dev` 分支。
## 功能模块 ## 功能模块
+1974
View File
File diff suppressed because it is too large. Load diff
+92 -17
View File
@@ -18,38 +18,107 @@
import json import json
import os import os
import ssl
import subprocess import subprocess
import sys
import time import time
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from functools import wraps from functools import wraps
import pymysql import pymysql
from flask import Flask, jsonify, render_template, request
from config_utils import get_env_value
try:
import certifi
_ssl_context = ssl.create_default_context(cafile=certifi.where())
except ImportError:
_ssl_context = ssl.create_default_context()
from flask import Flask, g, jsonify, render_template, request
from api_v2 import v2_bp
# ====================================================================== # ======================================================================
# Flask 应用实例 # Flask 应用实例
# ====================================================================== # ======================================================================
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
# 注册 v2 数据接口(/api/v2/*,为「阅间」新版前端提供聚合数据)
app.register_blueprint(v2_bp)
# ====================================================================== # ======================================================================
# 数据库连接配置 # 数据库连接配置
# ====================================================================== # ======================================================================
MYSQL_CONFIG = { MYSQL_CONFIG = {
"host": "127.0.0.1", # MySQL 服务器地址 # 默认使用远程 MySQL;仍可通过 WEREAD_DB_* 环境变量覆盖
"port": 3306, # MySQL 端口 "host": os.environ.get("WEREAD_DB_HOST", "124.222.38.85"),
"user": "root", # 数据库用户名 "port": int(os.environ.get("WEREAD_DB_PORT", 13306)),
"password": "258963", # 数据库密码 "user": os.environ.get("WEREAD_DB_USER", "root"),
"database": "weread_dashboard", # 数据库名称 "password": os.environ.get("WEREAD_DB_PASSWORD", "258963"),
"database": os.environ.get("WEREAD_DB_NAME", "weread_dashboard"),
"charset": "utf8mb4", # 字符集(支持完整 Unicode 含 emoji) "charset": "utf8mb4", # 字符集(支持完整 Unicode 含 emoji)
"cursorclass": pymysql.cursors.DictCursor, # 使用字典游标,方便按字段名取值 "cursorclass": pymysql.cursors.DictCursor, # 使用字典游标,方便按字段名取值
} }
# ======================================================================
# 网站操作日志
# ======================================================================
@app.before_request
def start_operation_log_timer():
"""记录请求开始时间,供 after_request 计算接口耗时。"""
g.operation_started_at = time.perf_counter()
@app.after_request
def write_operation_log(response):
"""将 API 请求写入 operation_logs;日志失败不影响业务响应。"""
if not request.path.startswith("/api/"):
return response
try:
started_at = getattr(g, "operation_started_at", time.perf_counter())
duration_ms = max(0, int((time.perf_counter() - started_at) * 1000))
path_parts = [part for part in request.path.split("/") if part]
action = "/".join(path_parts[-2:]) if len(path_parts) >= 2 else request.path
target_type = path_parts[-2] if len(path_parts) >= 2 else None
target_id = path_parts[-1] if len(path_parts) >= 2 and path_parts[-1] != target_type else None
detail = {"query": request.args.to_dict(flat=True)}
conn = pymysql.connect(**MYSQL_CONFIG)
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO operation_logs "
"(operation_type, action, target_type, target_id, keyword, request_path, "
"request_method, status_code, duration_ms, detail_json) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
"api_request",
action,
target_type,
target_id,
request.args.get("keyword"),
request.path,
request.method,
response.status_code,
duration_ms,
json.dumps(detail, ensure_ascii=False),
),
)
conn.commit()
finally:
conn.close()
except Exception:
# 日志属于旁路能力,数据库或表异常时不能影响正常页面/API 返回。
pass
return response
# ====================================================================== # ======================================================================
# 微信读书 API 配置 # 微信读书 API 配置
# ====================================================================== # ======================================================================
WEREAD_API_URL = "https://i.weread.qq.com/api/agent/gateway" WEREAD_API_URL = "https://i.weread.qq.com/api/agent/gateway"
WEREAD_API_KEY = os.environ.get("WEREAD_API_KEY", "")
WEREAD_SKILL_VERSION = "1.0.4" WEREAD_SKILL_VERSION = "1.0.4"
@@ -147,18 +216,22 @@ def weread_api_call(api_name, **kwargs):
Raises: Raises:
Exception: API 调用失败时抛出异常 Exception: API 调用失败时抛出异常
""" """
api_key = get_env_value("WEREAD_API_KEY", "")
if not api_key:
raise RuntimeError("WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc")
body = {"api_name": api_name, "skill_version": WEREAD_SKILL_VERSION} body = {"api_name": api_name, "skill_version": WEREAD_SKILL_VERSION}
body.update(kwargs) body.update(kwargs)
req = urllib.request.Request( req = urllib.request.Request(
WEREAD_API_URL, WEREAD_API_URL,
data=json.dumps(body).encode("utf-8"), data=json.dumps(body).encode("utf-8"),
headers={ headers={
"Authorization": f"Bearer {WEREAD_API_KEY}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
method="POST", method="POST",
) )
with urllib.request.urlopen(req, timeout=30) as resp: with urllib.request.urlopen(req, timeout=30, context=_ssl_context) as resp:
return json.loads(resp.read().decode("utf-8")) return json.loads(resp.read().decode("utf-8"))
@@ -1192,14 +1265,14 @@ def api_search(conn):
def sync_data(): def sync_data():
"""运行 fetch_data.py + migrate_to_mysql.py,全量同步数据""" """运行 fetch_data.py + migrate_to_mysql.py,全量同步数据"""
project_dir = os.path.dirname(os.path.abspath(__file__)) project_dir = os.path.dirname(os.path.abspath(__file__))
python_bin = os.path.join( python_bin = sys.executable or "python3"
os.path.expanduser("~"),
".workbuddy/binaries/python/envs/default/bin/python",
)
env = os.environ.copy() env = os.environ.copy()
env["WEREAD_API_KEY"] = env.get("WEREAD_API_KEY", "") env["WEREAD_API_KEY"] = get_env_value("WEREAD_API_KEY", "")
if not env["WEREAD_API_KEY"]: if not env["WEREAD_API_KEY"]:
return jsonify({"ok": False, "error": "WEREAD_API_KEY 未设置"}), 500 return jsonify({
"ok": False,
"error": "WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc",
}), 500
results = {"steps": []} results = {"steps": []}
try: try:
@@ -1270,5 +1343,7 @@ if __name__ == "__main__":
else: else:
print(f"[信息] {msg}") print(f"[信息] {msg}")
# 启动 Flask 服务,监听 8080 端口 # 监听端口:默认 8080,可用环境变量 PORT 覆盖(v2 前端与 v1 并存时可换端口)
app.run(host="0.0.0.0", port=8080, debug=False) port = int(os.environ.get("PORT", 8080))
print(f"[信息] 打开 http://127.0.0.1:{port} 查看「阅间」看板")
app.run(host="0.0.0.0", port=port, debug=False)
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
项目配置读取工具。
读取顺序:
1. 当前进程环境变量
2. 项目根目录下的 .env / .env.local
3. 用户常见 shell 配置文件中的 export 语句
"""
from pathlib import Path
import os
PROJECT_DIR = Path(__file__).resolve().parent
ENV_FILE_NAMES = (".env", ".env.local")
SHELL_RC_FILES = (
Path("~/.zshrc").expanduser(),
Path("~/.zprofile").expanduser(),
Path("~/.zlogin").expanduser(),
Path("~/.bash_profile").expanduser(),
Path("~/.bashrc").expanduser(),
Path("~/.profile").expanduser(),
)
def _parse_assignment(line):
"""解析 KEY=VALUE 或 export KEY=VALUE 形式的配置行。"""
stripped = line.strip()
if not stripped or stripped.startswith("#"):
return None
if stripped.startswith("export "):
stripped = stripped[7:].strip()
if "=" not in stripped:
return None
key, value = stripped.split("=", 1)
key = key.strip()
value = value.strip()
if not key or any(ch.isspace() for ch in key):
return None
if value[:1] in {"'", '"'} and value[-1:] == value[:1]:
value = value[1:-1]
else:
value = value.split(" #", 1)[0].strip()
return key, value
def _read_var_from_file(file_path, var_name):
"""从文本配置文件里读取指定变量,后出现的值优先。"""
try:
lines = file_path.read_text(encoding="utf-8").splitlines()
except OSError:
return ""
result = ""
for line in lines:
parsed = _parse_assignment(line)
if not parsed:
continue
key, value = parsed
if key == var_name and value:
result = value
return result
def _project_env_files(project_dir=None):
base_dir = Path(project_dir) if project_dir else PROJECT_DIR
return [base_dir / name for name in ENV_FILE_NAMES]
def load_project_env(project_dir=None):
"""把项目 .env 文件里的变量加载到当前进程环境中。"""
for file_path in _project_env_files(project_dir):
if not file_path.exists():
continue
try:
lines = file_path.read_text(encoding="utf-8").splitlines()
except OSError:
continue
for line in lines:
parsed = _parse_assignment(line)
if not parsed:
continue
key, value = parsed
if key and value and key not in os.environ:
os.environ[key] = value
def get_env_value(var_name, default="", project_dir=None):
"""按项目约定顺序读取配置值,并回填到当前进程环境。"""
value = os.environ.get(var_name, "")
if value:
return value
load_project_env(project_dir)
value = os.environ.get(var_name, "")
if value:
return value
for file_path in _project_env_files(project_dir):
value = _read_var_from_file(file_path, var_name)
if value:
os.environ[var_name] = value
return value
for file_path in SHELL_RC_FILES:
value = _read_var_from_file(file_path, var_name)
if value:
os.environ[var_name] = value
return value
return default
Executable
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# 服务器部署脚本:拉取代码、准备 .venv、安装依赖、执行迁移并重启服务。
set -euo pipefail
PROJECT_DIR="${PROJECT_DIR:-$(cd "$(dirname "$0")" && pwd)}"
REMOTE="${REMOTE:-origin}"
BRANCH="${DEPLOY_BRANCH:-${GITHUB_REF_NAME:-}}"
SERVICE_NAME="${SERVICE_NAME:-weread.service}"
VENV_DIR="${VENV_DIR:-$PROJECT_DIR/.venv}"
ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env}"
cd "$PROJECT_DIR"
if [ -z "$BRANCH" ]; then
BRANCH="$(git branch --show-current)"
fi
if [ -z "$BRANCH" ]; then
echo "未检测到部署分支,请设置 DEPLOY_BRANCH"
exit 1
fi
if [ "${SKIP_GIT_PULL:-0}" = "1" ]; then
echo "[1/5] 代码已拉取,跳过 git pull"
else
echo "[1/5] 拉取 $REMOTE/$BRANCH"
git fetch "$REMOTE"
git checkout "$BRANCH"
git pull --ff-only "$REMOTE" "$BRANCH"
fi
if [ -f "$ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
elif command -v systemctl >/dev/null 2>&1; then
SERVICE_ENV="$(systemctl show "$SERVICE_NAME" --property=Environment --value 2>/dev/null || true)"
for item in $SERVICE_ENV; do
case "$item" in
*=*) export "$item" ;;
esac
done
fi
find_python() {
if command -v python3 >/dev/null 2>&1; then
command -v python3
return
fi
if command -v python >/dev/null 2>&1; then
command -v python
return
fi
echo "未找到 python3 或 python,请先在服务器安装 Python"
exit 1
}
echo "[2/5] 检测 Python 虚拟环境"
if [ ! -x "$VENV_DIR/bin/python" ]; then
"$(find_python)" -m venv "$VENV_DIR"
fi
PYTHON="$VENV_DIR/bin/python"
echo "[3/5] 安装依赖"
if ! "$PYTHON" -m pip --version >/dev/null 2>&1; then
"$PYTHON" -m ensurepip --upgrade
fi
"$PYTHON" -m pip install -r requirements.txt
echo "[4/5] 执行数据库迁移"
"$PYTHON" - <<'PY'
from pathlib import Path
import pymysql
from api_v2 import MYSQL_CONFIG
migration = Path("migrations/001_reading_goals.sql")
if migration.exists():
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
for sql in migration.read_text(encoding="utf-8").split(";"):
if sql.strip():
cur.execute(sql)
conn.commit()
PY
echo "[5/5] 重启服务 $SERVICE_NAME"
if command -v systemctl >/dev/null 2>&1; then
if [ "$(id -u)" -eq 0 ]; then
systemctl restart "$SERVICE_NAME"
systemctl status "$SERVICE_NAME" --no-pager
else
sudo systemctl restart "$SERVICE_NAME"
sudo systemctl status "$SERVICE_NAME" --no-pager
fi
else
echo "当前系统没有 systemctl,请手动用 $PYTHON app.py 启动"
fi
+286
View File
@@ -0,0 +1,286 @@
# 微信读书数据看板最终文档
本文档用于替代 `.qoder/` 与 `.workbuddy/` 中分散的工具生成文档,作为当前仓库内的单一最终说明。内容以仓库现状为准,吸收了工具文档中的架构说明、接口说明、数据库分组、部署信息和迭代记录,同时剔除了已经过期或仅对工具自身有意义的中间信息。
## 1. 文档与目录约定
- 本文件是 `.qoder/repowiki` 与 `.workbuddy/memory` 的合并结果,也是这两个目录的人工可维护替代品。
- `.qoder/`、`.workbuddy/` 属于外部工具的中间产物,不参与 Git 管理。
- 深度技术细节仍保留在仓库原有文档中:
- [TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md)
- [METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md)
## 2. 项目定位
这是一个基于微信读书 Agent Gateway API 的个人阅读数据看板。系统将微信读书中的书架、阅读统计、笔记、推荐和搜索能力整合到一个本地运行的网站中,提供可视化图表、分页书架、笔记概览和按需详情查询。
当前实现采用:
- 后端:Flask + PyMySQL
- 前端:原生 HTML / CSS / JavaScript + ECharts
- 缓存与采集:SQLite
- 在线查询数据源:MySQL
- 外部能力:微信读书 Agent Gateway API
## 3. 当前仓库结构
核心文件与职责如下:
- [app.py](/Users/kinghwj/Codes/WeReadBoard/app.py):Flask 后端服务,提供页面和 `/api/*` 接口。
- [fetch_data.py](/Users/kinghwj/Codes/WeReadBoard/fetch_data.py):全量采集脚本,将微信读书数据写入 SQLite。
- [migrate_to_mysql.py](/Users/kinghwj/Codes/WeReadBoard/migrate_to_mysql.py):将 SQLite 数据迁移到 MySQL。
- [schema_mysql.sql](/Users/kinghwj/Codes/WeReadBoard/schema_mysql.sql):MySQL 31 张表建表语句。
- [config_utils.py](/Users/kinghwj/Codes/WeReadBoard/config_utils.py):运行时配置读取工具。
- [start_server.sh](/Users/kinghwj/Codes/WeReadBoard/start_server.sh):本地服务生命周期脚本。
- [templates/index.html](/Users/kinghwj/Codes/WeReadBoard/templates/index.html):页面骨架。
- [static/js/app.js](/Users/kinghwj/Codes/WeReadBoard/static/js/app.js):前端交互逻辑。
- [static/css/style.css](/Users/kinghwj/Codes/WeReadBoard/static/css/style.css):主题、布局和组件样式。
## 4. 系统架构
系统采用“采集 -> 迁移 -> 展示 -> 按需回填”的模式:
1. `fetch_data.py` 从微信读书 API 拉取全量数据,落入本地 `weread.db`。
2. `migrate_to_mysql.py` 将 SQLite 中的数据按表迁移到 MySQL `weread_dashboard`。
3. `app.py` 从 MySQL 读取基础数据,向前端提供 REST API。
4. 部分详情型接口会在用户点击时再去调用微信读书 API,并把结果缓存回 MySQL。
5. 前端点击“刷新”时会调用 `/api/sync`,串行执行全量采集与迁移。
## 5. 运行时配置
### 5.1 微信读书 API Key
当前代码通过 [config_utils.py](/Users/kinghwj/Codes/WeReadBoard/config_utils.py) 读取 `WEREAD_API_KEY`,优先级如下:
1. 当前进程环境变量
2. 项目根目录 `.env`
3. 项目根目录 `.env.local`
4. 用户 shell 配置文件,如 `~/.zshrc`、`~/.zprofile`、`~/.bash_profile`
这层逻辑的目的,是避免“终端里已经 export,但服务进程没有继承”导致的误报。
### 5.2 MySQL 配置
当前 MySQL 连接配置仍写在代码中,`app.py` 与 `migrate_to_mysql.py` 各维护一份:
- host: `127.0.0.1`
- port: `3306`
- user: `root`
- password: `258963`
- database: `weread_dashboard`
这部分目前能工作,但仍属于后续值得继续抽离的技术债。
### 5.3 端口与启动
- 默认端口:`8080`
- 可选环境变量:`WEREAD_PORT`
- 启动脚本使用当前机器上可用的 `python3` 或 `python`
常用命令:
```bash
pip install -r requirements.txt
python3 app.py
./start_server.sh daemon
./start_server.sh stop
./start_server.sh status
```
## 6. 主要功能
### 6.1 概览与图表
- 总阅读时长、阅读天数、书架数量、笔记总数
- 年度阅读趋势
- 24 小时时段分布
- 偏好分类
- 偏好作者
- 阅读时长排行
### 6.2 书架
- 关键词搜索书名 / 作者
- 分类筛选与父子分类树
- 仅看已读完
- 分页浏览
- 一键刷新同步微信读书数据
### 6.3 笔记与想法
- 笔记本概览
- 点击书籍查看按章节整理的划线、想法和点评
- 想法详情页按类型筛选
### 6.4 发现能力
- 为你推荐
- 搜索书籍
- 推荐 / 搜索结果写回本地缓存
## 7. API 总览
当前后端接口主要分为四类:
### 7.1 系统接口
- `GET /api/health`:数据库健康检查
- `POST /api/sync`:执行全量采集与迁移
### 7.2 基础统计接口
- `GET /api/overview`
- `GET /api/yearly-trend`
- `GET /api/top-books`
- `GET /api/categories`
- `GET /api/authors`
- `GET /api/time-distribution`
- `GET /api/read-stats`
- `GET /api/archives`
### 7.3 书架与笔记接口
- `GET /api/shelf`
- `GET /api/notebooks`
### 7.4 按需详情 / 发现接口
- `GET /api/book/detail`
- `GET /api/book/notes`
- `GET /api/recommend`
- `GET /api/search`
接口返回格式、参数细节和字段说明,仍以 [TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md) 为准。
## 8. 数据模型分组
当前 MySQL 使用 31 张表,按职责分为以下几组:
### 8.1 书架
- `books`
- `shelf_albums`
- `shelf_meta`
- `archives`
### 8.2 阅读统计
- `reading_stats`
- `read_times`
- `read_longest`
- `read_stat_items`
- `prefer_categories`
- `prefer_authors`
- `prefer_publishers`
- `prefer_cps`
- `prefer_books`
- `prefer_time`
- `read_rank`
- `medals`
- `year_reports`
### 8.3 笔记与书籍内容
- `notebooks`
- `book_highlights`
- `book_chapters`
- `book_reviews`
- `book_best_bookmarks`
- `book_underlines`
- `public_reviews`
### 8.4 书籍详情与进度
- `book_details`
- `book_progress`
### 8.5 推荐与搜索
- `recommended_books`
- `similar_books`
- `search_sessions`
- `search_results`
### 8.6 系统元信息
- `fetch_meta`
设计特征:
- MySQL 统一使用 `utf8mb4` + `InnoDB`
- 大多数时间字段采用“整型时间戳 + `_dt` 可读时间列”的双列模式
- 每张表统一维护 `created_at` 与 `updated_at`
完整字段说明见 [METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md)。
## 9. 前端实现要点
当前前端是单页标签切换式界面,主要包括:
- 主题切换:支持明暗主题与本地持久化
- 图表懒加载:首次进入图表页时才初始化 ECharts
- 高清封面策略:低清 URL 自动升级为高清版本
- 书架筛选:关键词、分类、已读完、分页
- 想法详情页:从弹窗升级为独立页面
- 刷新同步:按钮触发 `/api/sync` 后再刷新书架数据
## 10. 部署与运维要点
- MySQL 需要先执行 [schema_mysql.sql](/Users/kinghwj/Codes/WeReadBoard/schema_mysql.sql) 建库建表。
- 初次准备数据时,执行:
```bash
python3 fetch_data.py
python3 migrate_to_mysql.py
```
- 日常使用可直接启动 Web 服务,前端点“刷新”会自动触发同步。
- 若样式或静态资源修改后页面无变化,优先检查:
- 服务是否使用了旧进程
- 浏览器是否使用了缓存
## 11. 合并后的迭代记录
以下内容来自 `.workbuddy/memory/2026-07-17.md`,但已按主题重整并去掉工具内部流程描述。
### 11.1 数据层演进
- 项目由 SQLite 展示切换为 MySQL 展示。
- 建表从 18 张表扩展到 31 张表,对齐官方接口字段。
- 迁移脚本重写,支持全量导入与时间字段转换。
### 11.2 后端演进
- 新增 `db_required` 装饰器统一管理数据库连接。
- 新增 `/api/health` 用于前端健康检查。
- 新增 `/api/sync`,打通“采集 -> 迁移 -> 展示”的刷新流程。
- 新增按需详情接口,失败时可回退本地缓存。
### 11.3 前端演进
- 新增明暗主题切换和图表配色适配。
- 阅读排行从卡片改为列表。
- 想法详情从弹窗改为独立页面。
- 新增顶部搜索框、书架分类树、推荐与搜索页。
- 书架增加刷新按钮,并在后续多次修复其加载态抖动问题。
### 11.4 启动与配置演进
- `start_server.sh` 从硬编码路径改为动态识别项目目录与解释器。
- `WEREAD_API_KEY` 从单纯依赖当前 shell 环境,演进为支持 `.env` 和 shell 配置回退读取。
## 12. 当前已知技术债
- MySQL 连接配置仍硬编码在 `app.py` 和 `migrate_to_mysql.py`,存在重复维护。
- `README.md` 仍保留较早期的 SQLite 描述,后续可与当前架构再对齐。
- 当前仍以本地脚本为主,未引入更正式的进程管理和自动化部署方案。
## 13. 结论
现在仓库内推荐遵循的文档与资料体系如下:
- 最终总说明:本文件 [PROJECT_FINAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/PROJECT_FINAL_DOC.md)
- 技术展开说明:[TECHNICAL_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/TECHNICAL_DOC.md)
- 表结构与字段说明:[METADATA_DOC.md](/Users/kinghwj/Codes/WeReadBoard/docs/METADATA_DOC.md)
`.qoder/` 与 `.workbuddy/` 继续保留给工具使用即可,但不再视为仓库的正式文档来源。
+67
View File
@@ -0,0 +1,67 @@
# v2 数据接口(/api/v2/*)
「阅间」新版前端的数据来源。实现在 `api_v2.py`(Flask Blueprint,注册于 `app.py`),
v1 的 `/api/*` 接口保持不变,两套接口可同时使用。
## 数据来源与优先级
| 数据 | 来源 | 说明 |
|------|------|------|
| 书架、书目 | 微信读书 `/shelf/sync`(实时,进程内缓存 10 分钟) | 字段含书名、作者、分类、封面、读完标记、最近阅读时间 |
| 阅读统计(周/月/年/总) | 微信读书 `/readdata/detail`(实时,缓存 5 分钟) | 时长趋势、阅读天数、读得最多、偏好分类/作者/出版社/时段/勋章/好友排行 |
| 阅读进度 | 本地 `notebooks` / `book_progress` 缓存优先,缺失时并发调用 `/book/getprogress` 补齐(缓存 10 分钟) | 用于书架卡片与详情页进度 |
| 笔记本与笔记 | 本地 `notebooks` 表 + 实时 `/book/bookmarklist`、`/review/list/mine` | 划线与想法正文实时获取 |
| 书籍详情 | 实时 `/book/info`、`/book/getprogress`、`/book/chapterinfo`、`/book/bestbookmarks`、`/review/list`、`/book/similar` | 逐项降级,单项失败不影响整页 |
| 搜索 / 推荐 | 实时 `/store/search`(按 scope 切换类型)、`/book/recommend` | |
| 阅读目标 | MySQL `reading_goals` / `reading_goal_books` | 数据库持久化;旧浏览器目标可手动导入 |
MySQL 不可用时,阅读数据接口仍可降级运行(返回 `degraded: true`)。阅读目标必须持久化,其接口返回 503,不退回浏览器保存。
## 接口清单
| 方法 | 路径 | 参数 | 说明 |
|------|------|------|------|
| GET | `/api/v2/status` | — | 连接状态、书架规模、数据快照时间 |
| GET | `/api/v2/shelf` | `tab=all\|reading\|done\|wish`、`keyword`、`limit`、`offset`、`progress=1` | 书架列表 + 书单 + 有声书 + 文章收藏入口(`progress=1` 为首屏书籍实时补拉进度) |
| GET | `/api/v2/dashboard` | `period=week\|month\|year\|overall` | 整页概览:趋势柱状图、四张统计卡、读得最多、偏好方向、时段分布、阅读画像、年度小结 |
| GET | `/api/v2/notebooks` | — | 笔记本概览(有笔记的书 + 划线/想法/书签数量) |
| GET | `/api/v2/notes` | `book_id`(`all` 表示聚合笔记最多的前 N 本)、`limit` | 划线与想法正文,按时间倒序 |
| GET | `/api/v2/book/<book_id>` | — | 书籍详情聚合:简介 + 进度 + 目录 + 我的笔记 + 热门划线 + 点评 + 相似推荐 |
| GET | `/api/v2/book/<book_id>/reviews` | `type=0\|1\|2\|3\|4`(全部/推荐/不行/最新/一般)、`count` | 公开点评(按类型筛选) |
| GET | `/api/v2/book/<book_id>/highlights` | `chapter_uid`(0=全书) | 热门划线(人数、章节、位置、跳转链接) |
| GET | `/api/v2/book/<book_id>/underline-thoughts` | `chapter_uid`、`range` | 某条划线下的他人想法 |
| GET | `/api/v2/thought/<review_id>` | — | 单条想法详情:正文、划线原文、点赞、评论 |
| GET | `/api/v2/archives` | — | 书架书单及成员书籍 |
| GET | `/api/v2/recommend` | `maxIdx` | 个性化推荐 |
| GET | `/api/v2/search` | `keyword`、`scope=all\|books\|author\|lists\|audio\|fulltext\|novel\|account\|article` | 书城检索(按分组归一为书籍/作者/书单) |
| POST | `/api/sync` | — | v1 已有:重新采集数据并迁移到 MySQL |
## 与微信读书接口的字段口径
### 阅读目标接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/v2/goals` | 返回 `{"goals":[...]}`,成员按书籍 ID 排序 |
| POST | `/api/v2/goals` | 新建,成功返回 201 和 `{"goal":{...}}` |
| PUT | `/api/v2/goals/<id>` | 整体替换目标和书目,返回 `{"goal":{...}}` |
| DELETE | `/api/v2/goals/<id>` | 删除目标及其成员,返回 `{"ok":true}` |
| POST | `/api/v2/goals/import` | 请求 `{"goals":[...]}`,返回 `{"imported":新增数量}` |
新建、编辑和导入中的目标字段一致:
```json
{"id":"goal-example","name":"秋季阅读","start":"2026-09-01","end":"2026-09-30","ids":["123","456"],"sources":["文学书单"]}
```
ID 和书籍 ID 为 1 至 64 字符的字符串;名称去除首尾空格后为 1 至 60 字符。日期必须为有效的 `YYYY-MM-DD`,年份为 1000 至 9999,结束日期不早于开始日期。`ids` 为非空字符串数组;`sources` 为字符串数组,省略时默认为空。PUT 请求体 ID 必须与路径一致。
书籍和来源去重,未知书籍 ID 仍保留。错误返回 `{"error":"中文说明"}`:400 参数非法、404 目标不存在、409 新建 ID 冲突、503 数据库不可用。导入不覆盖已有 ID,失败时回滚整个批次。迁移与验证方式见 [技术指南](technical-guide.md)。
### 阅读数据字段
- 评分:接口 `newRating` 为 0~1000,接口层换算成 `score = newRating / 100`(前端按设计稿公式展示为 `评分 XX/100`),详情页展示 `rating = newRating / 10`。
- 封面:设计稿使用 4 种示意底色(`c1`~`c4`),由 `book_id` 哈希稳定映射;真实封面 URL 保留在 `cover_url`。
- 进度:统一到 0~100 整数;`status` 口径为「读完=done、有最近阅读=reading、从未读过=wish」。
- 周/月维度的统计接口不回偏好画像字段,接口层用「总计」口径补齐,并在 `preference_source` 中标注来源。
- 「文章收藏」按接口规范计为 1 个书架条目(`books + albums + mp`)。
+84
View File
@@ -0,0 +1,84 @@
# 阅间 Demo 设计与数据流
## 页面分工
统计并入阅读概览;导航保留阅读概览、我的书架、阅读目标、笔记与划线、发现好书。概览展示阅读统计和目标摘要,目标页管理多份具体阅读清单。
完整功能、业务规则、接口落点与状态见 [功能目标清单](feature-checklist.md)。该清单先于本轮实现建立,后续代码开发继续追加和更新。
此版本属于 2.0 设计探索。正式版本需要接入真实接口、完成验收并合并到正式 Web 项目的 `master`,随后再创建 `v2.0` 标签。
## 数据流
`demo-data.js` 提供样例书籍、书单及初始目标。`app.js` 根据当前页面和筛选条件渲染内容;用户输入写入目标草稿,验证通过后保存到浏览器,再更新目标列表。现有 Flask 服务和数据采集代码不参与本轮 Demo。
书籍卡片打开详情;详情页可选目录、个人笔记、热门划线、点评、相似书。个人笔记与公开点评使用不同数据集合。热门划线中的想法继续打开单条详情。
书籍、书单详情均可加入已有目标,也可带着已选内容创建新目标。
## 阅读目标口径与例子
目标字段:`id`、`name`、`start`、`end`、`ids`、`sources`。`ids` 存放去重后的书籍 ID;`sources` 记录曾加入的书单名称,作为来源说明,不表示持续关联。
例:单独选择已读完的《活着》,再加入包含《活着》《人生海海》《悉达多》的书单。保存后总数为 3 本、已读完为 1 本,进度为 33%。从目标中移除《人生海海》后,进度为 1 / 2,即 50%。
完成状态取书籍当前已读状态,不受目标起止日期约束。同一本书可计入多个目标。同一目标内不重复计数。
状态判断先检查是否全部读完,再检查日期。例如目标时间为未来一周,但其中两本书都已读完,直接显示已完成;尚未全部完成时,按日期显示未开始、进行中、已到期。
书单采用加入时的书目,不自动跟随外部增删。移除目标内一本书,只影响这个目标,不改变书架或其他目标。
## 实现位置
| 职责 | 位置 |
|---|---|
| 去重与完成率、状态 | `app.js`:`uniqueIds`、`goalMetrics` |
| 新建、编辑、书单混选 | `app.js`:`openGoalEditor`、`renderPicker`、`saveGoal` |
| 加入已有目标 | `app.js`:`chooseTarget`、`appendGoal` |
| 本地持久化与异常恢复 | `app.js`:初始加载、`persistGoals` |
| 页面与详情导航 | `app.js`:`navigate`、`renderPage`、`handleAction` |
| 笔记导出 | `app.js`:`exportNotes` |
用户输入的目标名称和来源在 HTML 渲染前转义。日期和内容在保存前校验;原生对话框提供焦点管理和 Escape 关闭。
## 部署流程
GitHub Actions 的 `.github/workflows/deploy.yml` 监听 `dev` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。
`deploy.sh` 以当前触发分支为准拉取代码,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,执行 `migrations/001_reading_goals.sql`,最后重启 `weread.service`。
例:用户把代码推送到 `dev` 分支,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。
## 示例与真实接口的边界
概览图表使用固定样例;目标日期状态使用浏览器当前本地日期。笔记导出包含当前选中书目的划线和个人想法全集,不受内容类型筛选影响,不包含书签内容及公开点评。
书架的有声内容完结不等于用户读完,所以未用其完结状态计算阅读目标。文章收藏是入口,不假装能直接列出内部文章。缺失资料显示暂无,不补造 ISBN 等字段。
正式接口接入单独推进,必须核对实时可用接口、鉴权、分页、错误、升级提示及字段口径;本轮 Demo 的完成状态不得用于宣称这些能力已真实接入。
## 正式看板的紧凑布局
正式看板在 `static/css/styles.css` 中统一调整页面密度。顶栏高度为桌面 58 px、手机 56 px;标题区采用更短的上下间距,隐藏页面标题前的英文装饰文字。标题、说明、筛选、内容依次展示,操作按钮在桌面与标题并排,手机上自然换行。
书架按可用宽度自动排列书籍,每列约 116 px 起步;手机固定两列。真实封面维持原比例,并受所在列宽度约束。例如,书架网格可用宽度为 900 px 时显示多列紧凑排布,手机宽度为 390 px 时显示 2 列,长书名仍使用已有省略规则。
目标介绍区在宽屏上把说明与统计并排,窄屏上改为上下排列;目标卡按可用宽度自动排列,每张卡约 280 px 起步,并缩小封面预览和上下间距。例如,一个包含 6 本书、已读完 2 本的月度目标仍展示原有完成数和进度,只减少卡片占位。页面渲染、筛选、目标计算和数据流保持由现有 JavaScript 实现,CSS 不改变业务口径。
## 正式看板的阅读目标持久化
正式看板使用 `static/js/app.js`、`static/js/weapi.js` 和 `api_v2.py`。本节覆盖正式页面;前文的 Demo 存储方式不适用于正式目标。
`reading_goals` 保存 `id`、`name`、`start_date`、`end_date`、来源书单名称 `sources` 和创建、修改时间。`reading_goal_books` 保存 `goal_id`、`book_id`,联合主键防止同一目标重复计书。目标 ID 沿用前端生成值,便于导入旧目标。来源名称保存为 JSON 数组,不表示持续关联书单。
目标成员只通过外键关联目标。删除目标时级联删除成员,书架不受影响;书籍移出书架时也不删除目标成员。迁移位于 `migrations/001_reading_goals.sql`,只需部署时执行,不在请求中自动建表。
页面加载时 GET 目标列表,新建时 POST,编辑及追加书籍时 PUT,删除时 DELETE。表单先校验名称、日期和书目,服务端再次校验。目标与成员在同一事务中提交,编辑时锁定目标父记录并整体替换成员,避免并发请求混入两份书目。多个浏览器同时编辑同一目标时,以最后成功保存的完整内容为准。
前端收到提交成功响应后更新内存并关闭表单。保存中阻止重复提交;目标列表未从数据库读取完成前,不打开新建或编辑窗口,避免初始读取失败状态覆盖刚保存的目标。新建重试保持原 ID。数据库错误转换为 503,回滚事务并让前端保留草稿。目标列表不使用进程缓存,刷新即读取数据库。
例:创建“秋季阅读”,日期为 `2026-09-01` 至 `2026-09-30`,输入书籍 `["A", "A", "B"]` 和来源 `["文学书单"]`。数据库生成 1 条目标、2 条成员。若 A 已读完而 B 未读完,页面显示 `1 / 2,50%`;B 暂时不在书架中时仍显示 `1 / 2,50%`,B 的资料占位显示“状态待同步”,可在编辑器移除。若用户明确移除 B,则显示 `1 / 1,100%`。
完成率和状态不写入目标表。当前已知已读状态用于计算,不要求在目标日期内读完;全部已读完优先显示“已完成”,否则按浏览器本地日期显示未开始、进行中或已到期,起止当天均属于进行中。未知状态不计为已完成,但仍计入分母。
旧数据仅在用户点击导入时发送给 `/api/v2/goals/import`。先校验整批,再在事务中逐项插入;相同 ID 已存在时保留数据库内容,批次内重复 ID 只处理第一条。例如库中已有 G1,导入 `[G1, G2, G2]` 返回 `{"imported":1}`,再次导入返回 `{"imported":0}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。
+65
View File
@@ -0,0 +1,65 @@
# dev 自动部署
GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `dev` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `dev`、安装依赖、执行阅读目标表迁移并重启服务。
## GitHub 配置
已配置的仓库 Secrets:
- `SERVER_HOST`
- `SERVER_PORT`
- `SERVER_SSH_KEY`
- `SERVER_USER`
可选再配置两个仓库 Variables:
- `DEPLOY_PATH`:服务器上的项目目录,例如 `/opt/WeReadBoard`
- `SERVICE_NAME`:systemd 服务名,例如 `weread.service`
未配置 Variables 时,workflow 默认使用 `/opt/weread-board/WeReadBoard` 和 `weread.service`。
## 服务器要求
服务器部署目录需要已经是本仓库的 Git checkout,并且能访问 `origin/dev`。运行服务的环境变量仍按现有应用配置,例如 `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 <服务名>`。
`deploy.sh` 会执行:
```bash
git fetch origin
git checkout dev
git pull --ff-only origin dev
python3 -m venv .venv # 仅 .venv 不存在时执行
.venv/bin/python -m pip install -r requirements.txt
# 读取 api_v2.MYSQL_CONFIG 后执行 migrations/001_reading_goals.sql
sudo systemctl restart "$SERVICE_NAME"
```
其中迁移实际通过 `api_v2.MYSQL_CONFIG` 读取数据库配置并执行 SQL 文件;SQL 使用 `CREATE TABLE IF NOT EXISTS`,可重复执行。
## systemd 示例
```ini
[Unit]
Description=WeReadBoard
After=network.target
[Service]
WorkingDirectory=/opt/weread-board/WeReadBoard
Environment=WEREAD_API_KEY=wrk-xxxxxxxx
Environment=WEREAD_DB_HOST=127.0.0.1
Environment=WEREAD_DB_PORT=3306
Environment=WEREAD_DB_USER=weread
Environment=WEREAD_DB_PASSWORD=change-me
Environment=WEREAD_DB_NAME=weread
Environment=PORT=8770
ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python app.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
如果服务名或部署目录不同,在仓库 Variables 中调整 `SERVICE_NAME` 和 `DEPLOY_PATH`。
+127
View File
@@ -0,0 +1,127 @@
# 阅间功能目标清单
> 创建日期:2026-09-11。本文先于本轮 Demo 开发建立,是后续实现、验收和追加需求的统一清单。
当前版本定位:本目录中的静态 Demo 是 2.0 设计探索,不是正式 2.0 发布。只有后续真实功能合并到正式 Web 项目的 `master` 并完成验收后,才创建 `v2.0` 标签。
## 使用方式
- 功能编号固定,后续需求追加新编号,不复用或删除旧编号。
- 分别维护「Demo 状态」与「真实接入状态」。假数据交互完成,不等于接口或正式功能完成。
- 状态使用:待实现、进行中、已验证、待接口验证、暂不支持。
- 实现后记录文件和函数或元素位置、验收结果;业务口径变化时同步更新规则与变更记录。
- 当前范围为优化本地假数据 Demo,不接入账号,不修改原 Python 业务逻辑,不推送。
## 已确认业务口径
| 编号 | 规则 |
|---|---|
| R01 | 合并阅读概览与阅读统计,统计在概览内展示,不设置重复导航。 |
| R02 | 支持多个阅读目标,每个目标有名称、开始日期、结束日期。结束日期不得早于开始日期。 |
| R03 | 目标可添加具体书籍、一个或多个书单,允许混合选择。 |
| R04 | 同一目标内按书籍 ID 去重;同一本书可属于多个目标。 |
| R05 | 已读完的书直接计为完成,不要求在目标时间范围内读完,不做重读。 |
| R06 | 完成率 = 已读完书籍数 / 去重后目标书籍数。创建目标至少选一本;空目标展示未开始,不除零。 |
| R07 | 时间范围仅安排计划和提示状态,不改变书籍是否读完。全部读完优先显示已完成;否则按未开始、进行中、已到期展示。 |
| R08 | 本版把书单当前成员加入目标并展示来源,保存时提示「加入当前书目」。书单后续变动不自动增删目标书籍;自动跟随属于后续待讨论项。 |
| R09 | Demo 目标保存在当前浏览器,刷新保留;不写回微信读书。现有文档没有目标写回接口。 |
| R10 | 接入时按接口已读完状态判断,不把进度 1 当作完成:书架 finishReading=1;独立进度需要 progress=100 且存在 finishTime。冲突、缺失数据需处理,不伪造已读状态。 |
| R11 | 书架条目数 = books.length + albums.length + (mp 非空 ? 1 : 0)。有声专辑「已完结」不是用户「已读完」。 |
| R12 | 笔记总数 = 划线数 + 想法/点评数 + 书签数。公开点评不计入个人笔记;书签目前仅有数量,无内容。 |
| R13 | 时间戳显示 YYYY-MM-DD;时长按秒转换为小时/分钟;评分按对应接口说明转换,不能混用百分制、五分制。 |
| R14 | 演示数据与真实数据明确区分。无 deepLink 不拼接阅读链接。可选字段缺失不当作 0,也不补造内容。 |
## 功能目标与追踪
以下位置已按本轮实现回填。函数名作为稳定定位依据,行号仅对应本次版本。
| ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 |
|---|---|---|---|---|
| F01 | 五个入口:概览、书架、目标、笔记、发现;暗亮切换及移动适配 | 已验证(示例) | 不涉及接口 | `web-demo/app.js:54` `navigate`; `web-demo/app.js:30` `setTheme` |
| F02 | 概览内切换周/月/年/总计,展示时长、天数、日均、排行、偏好 | 已验证(示例) | 待实现 | `web-demo/app.js:85` `renderOverview` |
| F03 | 多目标列表、名称、时间范围、封面组合、完成率、到期状态 | 已验证(示例) | 本地功能,待正式持久化 | `web-demo/app.js:76` `renderGoals`; `web-demo/app.js:67` `goalMetrics` |
| F04 | 创建与编辑目标,校验日期和非空内容;刷新保留 | 已验证(示例) | 本地功能,待正式持久化 | `web-demo/app.js:122` `openGoalEditor`; `web-demo/app.js:138` `saveGoal` |
| F05 | 目标混合选择单书与书单,去重,已读直接计完成 | 已验证(示例) | 待接真实书籍状态 | `web-demo/app.js:131` `selectedGoalBooks`; `web-demo/app.js:67` `goalMetrics` |
| F06 | 目标详情查看成员、来源、单本进度;编辑移除成员 | 已验证(示例) | 待接真实书籍状态 | `web-demo/app.js:118` `openGoalDetail`; `web-demo/app.js:122` `openGoalEditor` |
| F07 | 我的书架:在读/已读/想读、书单、有声书和文章收藏入口 | 已验证(示例) | 待实现 | `web-demo/app.js:105` `renderShelf` |
| F08 | 我的书单列表与详情;从书单添加目标 | 已验证(示例) | 待实现 | `web-demo/app.js:104` `renderLists`; `web-demo/app.js:113` `openList` |
| F09 | 发现页:书籍/书单搜索、公开书单详情示意、空结果 | 已验证(示例) | 搜索待实现;公开书单成员待接口验证 | `web-demo/app.js:158` `renderDiscover`; `web-demo/app.js:113` `openList` |
| F10 | 书籍详情:信息、阅读进度、目录;加入目标 | 已验证(示例) | 待实现 | `web-demo/app.js:192` `openBook`; `web-demo/app.js:197` `renderBookTab` |
| F11 | 笔记本、划线与个人想法、书签数量、按书过滤、内容导出 | 内容/导出预览已验证;文件下载待验证 | 待实现;书签内容暂不支持 | `web-demo/app.js:177` `renderNotes`; `web-demo/app.js:184` `exportNotes` |
| F12 | 热门划线、章节热度、划线下想法及想法详情 | 已验证(示例) | 待实现 | `web-demo/app.js:197` `renderBookTab`; `web-demo/app.js:208` `openThought` |
| F13 | 公开点评筛选、点评详情,与个人笔记分离 | 已验证(示例) | 待实现 | `web-demo/app.js:197` `renderBookTab`; `web-demo/app.js:208` `openThought` |
| F14 | 个性化推荐和相似书推荐,可查详情和加入目标 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover`; `web-demo/app.js:197` `renderBookTab` |
| F15 | 搜索其余类型:作者、网文、听书、全文、公众号、文章、综合 | 已验证(示例) | 待实现;非书籍回包结构需实测 | `web-demo/app.js:158` `renderDiscover` |
| F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` |
| F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 |
| F18 | 可选统计:阅读时段、作者/出版社/版权方、阅读听书比例、好友排行、勋章、年报 | 已验证(示例) | 待实现;按实际返回字段显示 | `web-demo/app.js:85` `renderOverview` |
## 全部文档接口的落点
依据本地 weread-skills 1.0.4 能力文档。未调用实时 `/_list`,因此此表是文档已知能力,不宣称已发现网关所有实际接口。
| 接口 | 使用场景 | 约束与后续接入要求 |
|---|---|---|
| /_list | 接入前核对网关可用接口 | 获取后补齐本清单;不可将未验证能力标完成 |
| /shelf/sync | 书架、我的书单、专辑、文章收藏 | archive 包含名称与 bookIds;mp 仅入口,不是文章内容 |
| /store/search | 综合、电子书、网文、听书、作者、全文、书单、公众号、文章 | 显式 scope;分页看 hasMore/searchIdx;书单成员结构待验证 |
| /book/info | 详情资料与评分 | 按返回字段展示,不补造 ISBN 等信息 |
| /book/chapterinfo | 目录与章节选择 | 使用 chapterUid,保留层级和付费状态 |
| /book/getprogress | 书籍进度、已读状态 | 进度 0–100;时长秒;最近阅读优先加载,避免全书架逐本请求 |
| /readdata/detail | 概览全部阅读统计 | 自然周期;总量用 totalReadTime;日均按自然日;preferTime 从 6 点开始;可选数据缺失时隐藏 |
| /user/notebooks | 笔记本与数量 | count + lastSort 分页;总数含书签 |
| /book/bookmarklist | 划线内容 | 不含书签内容 |
| /review/list/mine | 个人想法与点评 | 与划线合并导出;使用 bookid 参数及 synckey 分页 |
| /book/underlines | 章节划线热度 | 只有人数/位置等统计,不含文本 |
| /book/bestbookmarks | 热门划线 | 固定前 20 条,不支持分页 |
| /book/readreviews | 划线下想法 | 使用返回 range、chapterUid;每个 range 独立分页 |
| /review/single | 想法、点评详情 | 富文本正式接入时需净化后渲染 |
| /review/list | 公开点评及筛选 | 双层 review 包装;20/40/60/80/100 对应 1–5 星 |
| /book/recommend | 为你推荐 | 使用 searchIdx 分页 |
| /book/similar | 相似推荐 | 显式 count/maxIdx;后续携带 sessionId |
## 验收记录
2026-09-11 在本地浏览器实测,结果如下。均为假数据 Demo 验证,不代表真实接口验收。
| 场景 | 输入或操作 | 实际结果 |
|---|---|---|
| 导航合并 | 查看侧边栏 | 共 5 个入口,无独立阅读统计 |
| 日期校验 | 开始 2026-09-12,结束 2026-09-01 | 原生表单阻止保存,提示结束日期过早 |
| 非空校验 | 只填写名称与日期,未选择书籍 | 提示至少选择一本书或一个书单 |
| 混合去重 | 单独选《活着》,再加入含它的三本书单 | 已选 3 本,1 本已读完,保存显示 33% |
| 保存刷新 | 保存“周末慢读清单”后刷新 | 名称、日期、3 本书与进度保留 |
| 编辑移除 | 从上述目标移除《人生海海》 | 剩余 2 本,1 本完成,显示 50% |
| 已有目标去重 | 重复加入“心理学,向内走一程”书单 | 原目标仍为 3 本,1 本完成 |
| 书单查询 | 发现页选择书单,输入“心理学” | 仅显示匹配书单,详情可加入已有目标 |
| 书籍内容 | 三体 → 目录 → 示例章节 2 → 划线想法 → 详情 | 展示对应入口及示例内容 |
| 点评筛选 | 读者点评选择“一般” | 仅显示三星的一般点评示例 |
| 相似推荐 | 在书籍详情选择相似推荐 | 展示其他 4 本书,可进入详情 |
| 笔记边界 | 全部笔记 → 书签数量 | 总数 10,划线 2、想法 2、书签 6;不显示书签内容 |
| 导出预览 | 导出全部笔记 | 生成 4 条内容:2 条划线、2 条想法,包含演示声明 |
| 文件下载 | 等待浏览器下载事件 | 事件超时,未确认落盘;保留可复制预览和下载链接,后续实测下载 |
| 搜索类型 | 推荐、作者、网文、听书、全文、公众号、文章、综合 | 均切换为对应类型示意 |
| 失败重试 | 预览状态选失败,再点击重试 | 显示失败状态,重试恢复结果 |
| 统计周期 | 切换月、年、总计、周 | 图表与总量切换;年度最终核对为 128小时42分钟 |
| 书架边界 | 我的书单、有声书、文章收藏 | 展示书单、有声信息和收藏入口边界说明 |
| 主题与响应式 | 1440px 桌面、390px 手机,暗亮切换 | 桌面双列目标;手机单列且编辑弹窗可滚动,无横向溢出 |
| 静态检查 | Node 语法检查 app.js 和 demo-data.js | 均通过 |
可重复手工验收:新建空内容目标检查提示;选择《活着》和文学书单验证 1/3;刷新验证保存;移除一本验证 1/2。不要把验收用目标写入未来真实账号。
## 变更记录
| 日期 | 变更 | 依据 |
|---|---|---|
| 2026-09-11 | 建立清单;确认多目标、日期范围、单书与书单混合、已读直接完成 | 本任务用户需求 |
| 2026-09-11 | 完成第二版 Demo,回填代码位置与验收;真实接口接入保留待实现 | 本轮浏览器实测 |
## 后续追加模板
复制下面一行到功能目标表,分配下一个编号,并在完成时同步验收记录。
| ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 |
|---|---|---|---|---|
| F19 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 |
后续开发顺序:核对实时 `/_list` 与公开书单成员能力 → 服务端鉴权及只读接口 → 替换 Demo 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

@@ -0,0 +1,18 @@
# 阅读目标数据库持久化实施计划
**目标:** 将正式看板的阅读目标迁移到现有 MySQL,保留当前业务规则与浏览器旧数据导入入口。
**方案:** Flask v2 接口读写 `reading_goals`、`reading_goal_books`,前端仅在服务端确认后更新目标。沿用当前个人看板范围,不增加账户体系。书籍成员独立于书架缓存,完成率根据当前已知阅读状态计算。
**技术栈:** 现有 Flask、PyMySQL、原生 JavaScript,无新增依赖。
## 步骤与验收
- [x] 后端:在 `api_v2.py` 增加 GET/POST `/api/v2/goals`、PUT/DELETE `/api/v2/goals/<id>`、POST `/api/v2/goals/import`。JSON 字段沿用 `id/name/start/end/ids/sources`;列表返回 `{goals: [...]}`,写入返回 `{goal: ...}`,导入返回 `{imported: N}`。先补接口失败用例,再实现校验、原子事务、重复导入跳过已有 ID。
- [x] 表结构:新增 `migrations/001_reading_goals.sql`,只创建目标与成员两表;成员联合主键去重,仅对目标做级联删除。通过显式迁移应用,不在每次请求时建表。
- [x] 前端:在 `static/js/weapi.js` 复用请求封装支持 JSON 写入,在 `static/js/app.js` 接入加载、保存、追加、删除、导入和错误提示。保存中阻止重复提交,失败保留表单;旧 localStorage 不再作为运行时目标来源,也不删除旧备份。
- [x] 边界:缺失书籍仍保留 ID 和目标分母,显示“状态待同步”,允许在编辑器移除;书单来源仅保存名称和加入时书目;日期含起止当天,全部读完优先显示已完成。
- [x] 文档:同步 `docs/technical-guide.md`、`docs/code-design.md`、`docs/V2_API.md` 及正式页面提示,保留静态 Demo 的独立说明。
- [x] 验证:使用标准库测试验证非法输入、重复 ID、事务回滚与数据库异常;真实 MySQL 验证新建、重读、编辑、导入和删除,仅操作唯一测试 ID。检查 JavaScript 语法并通过浏览器完成新建、刷新、编辑和清理闭环。
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
+149
View File
@@ -0,0 +1,149 @@
# 阅间设计 Demo 使用说明
## 范围与启动
这是独立的静态设计 Demo,不调用微信读书接口,不需要 API Key,也不修改现有 Flask 服务、数据采集脚本和 8080 页面。功能与接口的后续实现进度统一维护在 [功能目标清单](feature-checklist.md)。
该 Demo 定位为 2.0 设计探索。当前提交用于保存探索成果,不代表 2.0 正式发布,也不创建 `v2.0` 标签。
在项目根目录执行:
```sh
python3 -m http.server 8765 --bind 127.0.0.1 --directory web-demo
```
访问 `http://127.0.0.1:8765`。也可以直接打开 `web-demo/index.html`;推荐通过本地服务器使用,以便浏览器稳定保存目标数据。
无需安装前端依赖。页面由以下文件构成:
| 文件 | 内容 |
|---|---|
| `web-demo/index.html` | 页面外壳、导航、详情与目标编辑弹窗 |
| `web-demo/styles.css` | 暗亮主题、书封示意、响应式排版 |
| `web-demo/demo-data.js` | 书籍、书单、初始目标、笔记与点评样例 |
| `web-demo/app.js` | 页面渲染、目标操作、搜索筛选、导出 |
## 保存与数据边界
目标使用当前来源的 `localStorage` 键 `weread-demo-goals-v2`,主题使用 `weread-demo-theme`。刷新后保留;更换浏览器、地址或端口不会自动迁移;清除网站数据会清除本地目标。
书单加入目标时保存当前书籍集合,后续不自动跟随书单变化。同一目标内书籍去重,已读完直接计为完成。新增目标必须有名称、有效时间范围和至少一本书。
本地存储读取失败时显示示例目标并提示,不自动覆盖原存储;保存失败时保留编辑表单并提示重试。
所有阅读数据、书单成员、封面、目录、笔记和点评都是示例。用户编辑的目标只保存在本机,不会写回微信读书。公开书单完整成员获取方式仍需接口验证。
## 使用入口
- 阅读目标:新建目标,填写日期,勾选书籍或点击书单快捷加入,保存后可再编辑。
- 我的书架:切换我的书单、有声书、文章收藏与阅读状态。
- 发现好书:选择搜索类型;右侧预览状态可查看加载、空结果、失败及重试效果。
- 书籍详情:从卡片进入,切换目录、个人笔记、热门划线、点评、相似推荐。
- 笔记:按书目和类型筛选,导出当前书目的划线与想法,书签仅显示数量。
## 验证
语法检查:
```sh
node --check web-demo/app.js
node --check web-demo/demo-data.js
```
交互验收记录见 [功能目标清单](feature-checklist.md)。真实 API、鉴权、分页和服务器持久化尚未验证。
## 与 1.0 的关系
1.0 继续由根目录的 `app.py`、`fetch_data.py`、`templates/` 和 `static/` 提供,运行在 8080 端口。2.0 探索 Demo 位于 `web-demo/`,两者可以独立预览。
## 正式看板的紧凑样式
正式看板由 `templates/index.html` 和 `static/css/styles.css` 提供,使用现有启动方式,不新增依赖、配置或环境变量。上文的 `web-demo/` 是独立的历史设计 Demo,本次样式调整仅应用于正式看板。
刷新正式看板即可加载样式;若浏览器仍显示旧间距,强制刷新页面。检查阅读概览、我的书架、阅读目标在桌面和手机宽度下的展示,并切换暗色主题。目标数据的保存方式由当前应用逻辑决定,样式不参与存储。
## 正式看板的阅读目标数据库
正式页面的目标通过 `/api/v2/goals` 保存到 MySQL。浏览器只保留编辑草稿和页面状态,刷新或换浏览器后从同一服务读取目标。上文 `web-demo/` 的本地存储说明仅适用于静态 Demo。
沿用 `api_v2.py` 的 `WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME` 配置,不新增依赖。服务仍使用当前个人账号的数据;多个浏览器连接同一服务时共享目标,没有新增多用户隔离。
部署新代码前,在项目根目录执行一次迁移。下面的命令复用服务配置,只创建两张目标表,不修改已有业务表:
```sh
python3 - <<'PY'
from pathlib import Path
import pymysql
from api_v2 import MYSQL_CONFIG
with pymysql.connect(**MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
for sql in Path('migrations/001_reading_goals.sql').read_text().split(';'):
if sql.strip():
cur.execute(sql)
print('阅读目标表已就绪')
PY
PORT=8080 python3 app.py
```
使用已安装 `requirements.txt` 的 Python 环境。若 8080 已有本项目实例,先停止旧实例再启动,使 Flask 加载新接口。迁移可重复执行;运行账户需要目标表的读写权限。目标表不参与 `fetch_data.py` 的采集覆盖。
## GitHub 自动部署
服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、执行阅读目标迁移,并重启 systemd 服务。
当前服务器配置可按下列默认值使用:
```sh
PROJECT_DIR=/opt/weread-board/WeReadBoard
SERVICE_NAME=weread.service
VENV_DIR=/opt/weread-board/WeReadBoard/.venv
```
GitHub Actions 文件位于 `.github/workflows/deploy.yml`,默认在 `dev` 分支 push 后触发,也支持手动触发。仓库需要在 `Settings -> Secrets and variables -> Actions -> Secrets` 配置:
| 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` |
如果在服务器手动部署,可执行:
```sh
cd /opt/weread-board/WeReadBoard
DEPLOY_BRANCH=dev ./deploy.sh
```
`deploy.sh` 会优先读取项目根目录 `.env`;没有 `.env` 时,会读取 `weread.service` 当前 `Environment=` 配置。按截图中的 service 写法也能拿到端口、API Key 和数据库配置。若以后改用普通部署用户,需要允许该用户免密执行 `systemctl restart weread.service`。
浏览器旧目标不会自动覆盖数据库。在原访问地址打开“阅读目标”,点击“导入已有目标”;数据库已有同一 ID 时跳过,整批成功才标记导入完成。`weread-goals-v2` 原数据保留,`weread-goals-v2-imported` 只记录已导入。不同浏览器、域名和端口的旧存储需要分别在原地址导入。
保存失败会保留表单并提示重试;读取失败显示错误和重试按钮,不冒充空列表。若新建提示 ID 已存在,刷新目标列表确认上次请求是否已经保存。数据库不可用或尚未执行迁移时,目标接口返回 503,不退回浏览器保存。
验证命令:
```sh
python3 -m unittest discover -s tests -p 'test_reading_goals*.py' -v
node --test tests/goals_frontend.test.cjs
node --check static/js/app.js
node --check static/js/weapi.js
```
真实数据库验证需先执行迁移,再显式运行 `WEREAD_TEST_MYSQL=1 python3 -m unittest discover -s tests -p 'test_reading_goals_mysql.py' -v`。测试创建唯一测试目标并清理,只操作本次生成的 ID。
本次样式收紧覆盖正式看板的概览、书架和阅读目标页。顶栏、标题区、筛选区、统计卡、书架网格、右侧目标摘要、目标介绍区和目标卡片均使用覆盖规则压缩占位;书架在窄屏固定两列,避免横向溢出。本地真实数据验收覆盖 1280 px 桌面和 390 px 手机宽度:桌面顶栏 58 px,统计卡从约 200 px 开始,书架一行 7 本,目标页介绍区约 67 px 高,目标卡约 317 px 宽;手机顶栏 56 px,书架两列,目标卡约 298 px 宽,概览、书架和目标页均未出现横向溢出。
## dev 自动部署
`dev` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/dev`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行阅读目标表迁移和重启 systemd 服务。详细配置见 `docs/deploy-dev.md`。
本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
+9 -4
View File
@@ -26,9 +26,10 @@ import time
import urllib.request import urllib.request
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from config_utils import get_env_value
# ===== 配置 ===== # ===== 配置 =====
API_URL = "https://i.weread.qq.com/api/agent/gateway" API_URL = "https://i.weread.qq.com/api/agent/gateway"
API_KEY = os.environ.get("WEREAD_API_KEY", "")
SKILL_VERSION = "1.0.4" SKILL_VERSION = "1.0.4"
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db") DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db")
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -54,13 +55,17 @@ def now_str():
def api_call(api_name, **kwargs): def api_call(api_name, **kwargs):
"""调用微信读书 API""" """调用微信读书 API"""
api_key = get_env_value("WEREAD_API_KEY", "")
if not api_key:
raise RuntimeError("WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc")
body = {"api_name": api_name, "skill_version": SKILL_VERSION} body = {"api_name": api_name, "skill_version": SKILL_VERSION}
body.update(kwargs) body.update(kwargs)
req = urllib.request.Request( req = urllib.request.Request(
API_URL, API_URL,
data=json.dumps(body).encode("utf-8"), data=json.dumps(body).encode("utf-8"),
headers={ headers={
"Authorization": f"Bearer {API_KEY}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
method="POST", method="POST",
@@ -1020,9 +1025,9 @@ def fetch_notebooks(conn):
# ===== 主函数 ===== # ===== 主函数 =====
def main(): def main():
if not API_KEY: if not get_env_value("WEREAD_API_KEY", ""):
print("错误: 请先设置 WEREAD_API_KEY 环境变量") print("错误: 请先设置 WEREAD_API_KEY 环境变量")
print(" export WEREAD_API_KEY=wrk-xxxxxxxx") print(" 可配置在当前终端、项目 .env,或 ~/.zshrc")
return return
print("=" * 60) print("=" * 60)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+211
View File
@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的读书数据</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<!-- 防闪烁:在 CSS 加载后、页面渲染前设置主题 -->
<script>
(function() {
var saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<!-- 数据库连接告警横幅(MySQL 连接失败时显示) -->
<div id="dbAlertBanner" class="db-alert-banner" style="display:none;">
<span class="db-alert-icon">⚠️</span>
<span class="db-alert-text">MySQL 数据库连接失败,数据可能无法正常加载。请检查 MySQL 服务是否已启动。</span>
<span class="db-alert-detail" id="dbAlertDetail"></span>
</div>
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-container">
<div class="nav-logo" onclick="switchTab('overview')" style="cursor:pointer">
<span class="logo-icon">📖</span>
<span class="logo-text">我的读书数据</span>
</div>
<div class="nav-links">
<a class="nav-link active" data-tab="overview" onclick="switchTab('overview')">总览</a>
<a class="nav-link" data-tab="charts" onclick="switchTab('charts')">数据图表</a>
<a class="nav-link" data-tab="top-books" onclick="switchTab('top-books')">读书排行</a>
<a class="nav-link" data-tab="shelf" onclick="switchTab('shelf')">书架</a>
<a class="nav-link" data-tab="notes" onclick="switchTab('notes')">笔记</a>
<a class="nav-link" data-tab="discover" onclick="switchTab('discover')">推荐</a>
</div>
<div class="nav-right">
<span class="nav-update" id="fetchTime"></span>
<button class="theme-toggle" onclick="toggleTheme()" title="切换明亮/黑暗模式" aria-label="切换主题">
<span class="icon-sun">☀️</span>
<span class="icon-moon">🌙</span>
</button>
</div>
</div>
</nav>
<!-- 顶部常驻搜索框 -->
<div class="global-search-bar">
<div class="container">
<div class="global-search-wrapper">
<span class="global-search-icon">🔍</span>
<input type="text" id="globalSearchInput" class="global-search-input" placeholder="搜索图书..." onkeydown="if(event.key==='Enter')globalSearch()">
<button class="global-search-btn" onclick="globalSearch()">搜索</button>
</div>
</div>
</div>
<!-- ===== 主视图(标签页切换) ===== -->
<main id="mainView">
<!-- 总览标签页 -->
<section id="tab-overview" class="tab-panel active">
<div class="hero-section">
<div class="hero-bg"></div>
<div class="hero-content">
<h1 class="hero-title">阅读,是一场漫长的旅行</h1>
<p class="hero-subtitle" id="heroSubtitle">正在加载数据...</p>
<div class="hero-stats" id="heroStats"></div>
</div>
</div>
<section class="stats-cards-section">
<div class="container">
<div class="stats-grid" id="statsGrid"></div>
</div>
</section>
</section>
<!-- 数据图表标签页 -->
<section id="tab-charts" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">📊 阅读数据可视化</h2>
<div class="charts-grid">
<div class="chart-card large">
<h3 class="chart-title">年度阅读时长趋势</h3>
<div id="chart-yearly" class="chart-container"></div>
</div>
<div class="chart-card">
<h3 class="chart-title">24小时阅读时段分布</h3>
<div id="chart-hours" class="chart-container"></div>
</div>
<div class="chart-card">
<h3 class="chart-title">偏好阅读分类</h3>
<div id="chart-categories" class="chart-container"></div>
</div>
<div class="chart-card large">
<h3 class="chart-title">偏好作者 TOP 10</h3>
<div id="chart-authors" class="chart-container"></div>
</div>
</div>
</div>
</section>
<!-- 读书排行标签页 -->
<section id="tab-top-books" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">🏆 阅读时长排行</h2>
<div class="top-books-list" id="topBooksGrid"></div>
</div>
</section>
<!-- 书架标签页 -->
<section id="tab-shelf" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">📚 我的书架</h2>
<div class="shelf-controls">
<div class="shelf-filters">
<input type="text" id="shelfSearch" class="shelf-search" placeholder="搜索书名、作者...">
<label class="filter-checkbox">
<input type="checkbox" id="finishedOnly"> 仅看已读完
</label>
<button class="shelf-refresh-btn" id="shelfRefreshBtn" onclick="refreshShelf()" title="重新加载书架数据">
<span class="btn-icon">⟳</span>
<span class="btn-text">刷新</span>
</button>
</div>
<div class="shelf-info" id="shelfInfo"></div>
</div>
<div class="shelf-categories" id="shelfCategories"></div>
<div class="book-grid" id="bookGrid"></div>
<div class="pagination" id="pagination"></div>
</div>
</section>
<!-- 笔记标签页 -->
<section id="tab-notes" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">✍️ 笔记概览</h2>
<div class="notes-summary" id="notesSummary"></div>
<div class="notebooks-grid" id="notebooksGrid"></div>
</div>
</section>
<!-- 推荐标签页 -->
<section id="tab-discover" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">🔍 推荐发现</h2>
<div class="discover-tabs">
<button class="discover-tab active" id="tabRecommend" onclick="switchDiscoverTab('recommend')">为你推荐</button>
<button class="discover-tab" id="tabSearch" onclick="switchDiscoverTab('search')">搜索书籍</button>
</div>
<div id="recommendPanel" class="discover-panel">
<div class="discover-controls">
<button class="btn-primary" onclick="loadRecommend(0)">刷新推荐</button>
<button class="btn-secondary" onclick="loadMoreRecommend()">换一批</button>
</div>
<div class="discover-grid" id="recommendGrid"></div>
</div>
<div id="searchPanel" class="discover-panel" style="display:none">
<div class="search-box">
<input type="text" id="searchInput" class="search-input" placeholder="输入书名、作者或关键词..." onkeydown="if(event.key==='Enter')doSearch()">
<button class="btn-primary" onclick="doSearch()">搜索</button>
</div>
<div class="discover-grid" id="searchGrid"></div>
</div>
</div>
</section>
</main>
<!-- ===== 想法详情独立页面 ===== -->
<div id="thoughtsPage" class="thoughts-page" style="display:none">
<div class="thoughts-page-header">
<div class="container">
<button class="back-btn" onclick="closeThoughtsPage()">← 返回</button>
<div class="thoughts-book-header" id="thoughtsBookHeader"></div>
</div>
</div>
<div class="container thoughts-content" id="thoughtsContent">
<div class="loading"><div class="loading-spinner"></div></div>
</div>
</div>
<!-- 书籍详情弹窗 -->
<div class="modal-overlay" id="bookDetailModal" onclick="closeModal('bookDetailModal')">
<div class="modal-content book-detail-modal" onclick="event.stopPropagation()">
<span class="modal-close" onclick="closeModal('bookDetailModal')">&times;</span>
<div id="bookDetailBody" class="modal-body"></div>
</div>
</div>
<!-- 回到顶部按钮 -->
<button id="backToTop" class="back-to-top" onclick="scrollToTop()" title="回到顶部" aria-label="回到顶部">↑</button>
<!-- 页脚 -->
<footer class="footer">
<div class="container">
<p>数据来源:微信读书 · 数据存储:本地 MySQL · 更新时间:<span id="footerTime"></span></p>
<p class="footer-hint">点击书籍封面可在微信读书 App 中打开</p>
</div>
</footer>
<script src="/static/js/app.js"></script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""将本地 MySQL 数据迁移到远程 MySQL。
默认配置:
源库:127.0.0.1:3306/weread_dashboard
目标库:124.222.38.85:13306/weread_dashboard
密码优先从环境变量读取:
SOURCE_DB_PASSWORD / TARGET_DB_PASSWORD
脚本不会创建数据库或表;请先在目标 MySQL 执行 schema_mysql.sql。
目标表会被清空后重新写入,因此执行前请确认目标库中没有需要保留的数据。
"""
import argparse
import os
import sys
from typing import Dict, Iterable, List, Tuple
import pymysql
def db_config(prefix: str, defaults: Dict[str, object]) -> Dict[str, object]:
return {
"host": os.getenv(f"{prefix}_DB_HOST", defaults["host"]),
"port": int(os.getenv(f"{prefix}_DB_PORT", defaults["port"])),
"user": os.getenv(f"{prefix}_DB_USER", defaults["user"]),
"password": os.getenv(f"{prefix}_DB_PASSWORD", ""),
"database": os.getenv(f"{prefix}_DB_NAME", defaults["database"]),
"charset": "utf8mb4",
"autocommit": False,
}
SOURCE = db_config("SOURCE", {
"host": "127.0.0.1", "port": 3306, "user": "root", "database": "weread_dashboard"
})
TARGET = db_config("TARGET", {
"host": "124.222.38.85", "port": 13306, "user": "root", "database": "weread_dashboard"
})
def q(identifier: str) -> str:
"""安全引用 MySQL 标识符。表名和列名均来自数据库元数据。"""
return "`" + identifier.replace("`", "``") + "`"
def tables(conn) -> List[str]:
with conn.cursor() as cur:
cur.execute("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'")
return [row[0] for row in cur.fetchall()]
def columns(conn, table: str) -> List[str]:
with conn.cursor() as cur:
cur.execute(f"SHOW COLUMNS FROM {q(table)}")
return [row[0] for row in cur.fetchall()]
def row_count(conn, table: str) -> int:
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM {q(table)}")
return int(cur.fetchone()[0])
def migrate_table(source, target, table: str, batch_size: int) -> Tuple[int, int]:
source_cols = columns(source, table)
target_cols = set(columns(target, table))
common = [col for col in source_cols if col in target_cols]
if not common:
raise RuntimeError(f"表 {table} 没有可写入的同名列")
col_sql = ", ".join(q(col) for col in common)
placeholders = ", ".join(["%s"] * len(common))
insert_sql = f"INSERT INTO {q(table)} ({col_sql}) VALUES ({placeholders})"
copied = 0
with source.cursor() as sc, target.cursor() as tc:
sc.execute(f"SELECT {col_sql} FROM {q(table)}")
while True:
batch = sc.fetchmany(batch_size)
if not batch:
break
tc.executemany(insert_sql, batch)
copied += len(batch)
return copied, len(source_cols) - len(common)
def migrate(batch_size: int, only: Iterable[str] = ()) -> None:
source = pymysql.connect(**SOURCE)
target = pymysql.connect(**TARGET)
try:
source_tables = tables(source)
target_tables = set(tables(target))
selected = list(only) if only else source_tables
missing = [t for t in selected if t not in target_tables]
if missing:
raise RuntimeError("目标库缺少表:" + ", ".join(missing))
print(f"源库: {SOURCE['host']}:{SOURCE['port']}/{SOURCE['database']}")
print(f"目标库: {TARGET['host']}:{TARGET['port']}/{TARGET['database']}")
print(f"迁移表数: {len(selected)},批大小: {batch_size}\n")
with target.cursor() as cur:
cur.execute("SET FOREIGN_KEY_CHECKS = 0")
for table in selected:
before = row_count(source, table)
cur.execute(f"TRUNCATE TABLE {q(table)}")
copied, skipped_cols = migrate_table(source, target, table, batch_size)
target.commit()
after = row_count(target, table)
note = f",跳过源表独有列 {skipped_cols} 个" if skipped_cols else ""
status = "✓" if before == after == copied else "!"
print(f"{status} {table:32s} 源 {before:8d} / 目标 {after:8d}{note}")
cur.execute("SET FOREIGN_KEY_CHECKS = 1")
target.commit()
except Exception:
target.rollback()
raise
finally:
source.close()
target.close()
def main() -> int:
parser = argparse.ArgumentParser(description="本地 MySQL → 远程 MySQL 数据迁移")
parser.add_argument("--batch-size", type=int, default=1000)
parser.add_argument("--tables", nargs="*", help="只迁移指定表;不传则迁移源库全部表")
args = parser.parse_args()
if args.batch_size < 1:
parser.error("--batch-size 必须大于 0")
try:
migrate(args.batch_size, args.tables or ())
print("\n迁移完成。")
return 0
except pymysql.MySQLError as exc:
print(f"MySQL 错误:{exc}", file=sys.stderr)
return 2
except Exception as exc:
print(f"迁移失败:{exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+20
View File
@@ -0,0 +1,20 @@
-- 显式执行一次;只创建阅读目标相关表,不修改已有业务表。
CREATE TABLE IF NOT EXISTS reading_goals (
id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
name VARCHAR(60) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
sources JSON NOT NULL COMMENT '加入目标时的书单名称,不追踪书单后续变更',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='阅读目标';
CREATE TABLE IF NOT EXISTS reading_goal_books (
goal_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
book_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (goal_id, book_id),
CONSTRAINT fk_reading_goal_books_goal FOREIGN KEY (goal_id)
REFERENCES reading_goals (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='目标书籍成员;不关联书架表,避免移出书架时丢失目标成员';
+1
View File
@@ -1 +1,2 @@
flask>=3.0 flask>=3.0
PyMySQL>=1.1
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# ==========================================================================
# 阅间 · 微信读书看板 v2 —— 启动脚本
# --------------------------------------------------------------------------
# 用法:
# ./start_dashboard.sh # 默认 8770 端口
# PORT=8080 ./start_dashboard.sh # 指定端口(注意 8080 可能被旧实例占用)
#
# 需要设置 WEREAD_API_KEY;MySQL 不可用时服务仍会启动(部分功能降级)。
# ==========================================================================
set -euo pipefail
cd "$(dirname "$0")"
PROJECT_PYTHON="$(pwd)/.venv/bin/python"
PYTHON_BIN="${PYTHON_BIN:-$PROJECT_PYTHON}"
if [ ! -x "$PYTHON_BIN" ]; then
PYTHON_BIN="$HOME/.workbuddy/binaries/python/envs/default/bin/python"
fi
if [ ! -x "$PYTHON_BIN" ]; then
PYTHON_BIN="$(command -v python3)"
fi
export WEREAD_API_KEY="${WEREAD_API_KEY:-}"
export PORT="${PORT:-8770}"
if [ -z "$WEREAD_API_KEY" ]; then
echo "[警告] WEREAD_API_KEY 未设置,书籍详情 / 笔记 / 推荐 / 搜索等实时接口将不可用"
echo " export WEREAD_API_KEY=wrk-xxxxxxxx"
fi
echo "[信息] Python: $PYTHON_BIN"
echo "[信息] 启动端口: $PORT"
exec "$PYTHON_BIN" app.py
+54 -16
View File
@@ -7,11 +7,17 @@
# ./start_server.sh restart 重启服务 # ./start_server.sh restart 重启服务
# ./start_server.sh status 查看状态 # ./start_server.sh status 查看状态
PROJECT_DIR="/Users/hwj/WorkBuddy/2026-07-17-09-48-20" PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
PYTHON="/Users/hwj/.workbuddy/binaries/python/envs/default/bin/python" if [ -x "$PROJECT_DIR/.venv/bin/python" ]; then
DEFAULT_PYTHON="$PROJECT_DIR/.venv/bin/python"
else
DEFAULT_PYTHON="$(command -v python3 || command -v python)"
fi
PYTHON="${PYTHON:-$DEFAULT_PYTHON}"
PID_FILE="$PROJECT_DIR/.server.pid" PID_FILE="$PROJECT_DIR/.server.pid"
LOG_FILE="/tmp/weread_server.log" LOG_FILE="/tmp/weread_server.log"
PORT=8080 # 默认从 8080 启动;也可通过 PORT 或 WEREAD_PORT 指定端口
PORT="${PORT:-${WEREAD_PORT:-8080}}"
cd "$PROJECT_DIR" || exit 1 cd "$PROJECT_DIR" || exit 1
@@ -22,10 +28,6 @@ is_running() {
return 0 return 0
fi fi
fi fi
# 也检查端口是否被占用
if lsof -i :$PORT -t >/dev/null 2>&1; then
return 0
fi
return 1 return 1
} }
@@ -37,7 +39,27 @@ get_pid() {
return return
fi fi
fi fi
lsof -i :$PORT -t 2>/dev/null | head -1 return 0
}
find_free_port() {
while lsof -iTCP:"$PORT" -sTCP:LISTEN -t >/dev/null 2>&1; do
PORT=$((PORT + 1))
done
}
get_running_port() {
local pid=$(get_pid)
local running_port
if [ -n "$pid" ]; then
running_port=$(lsof -Pan -p "$pid" -iTCP -sTCP:LISTEN 2>/dev/null \
| awk 'NR > 1 {print $9}' \
| sed -E 's/.*:([0-9]+)$/\1/' \
| head -1)
fi
echo "${running_port:-$PORT}"
} }
stop_server() { stop_server() {
@@ -58,27 +80,43 @@ stop_server() {
start_foreground() { start_foreground() {
if is_running; then if is_running; then
echo "服务已在运行 (PID: $(get_pid))" local running_port=$(get_running_port)
echo "服务已在运行 (PID: $(get_pid), 端口: $running_port)"
echo "访问地址: http://127.0.0.1:$running_port"
exit 1 exit 1
fi fi
echo "启动服务(前台模式)..." if [ -z "$PYTHON" ]; then
exec "$PYTHON" app.py echo "未找到可用的 Python 解释器"
exit 1
fi
echo "启动服务(前台模式,端口: $PORT)..."
echo "访问地址: http://127.0.0.1:$PORT"
PORT="$PORT" WEREAD_PORT="$PORT" exec "$PYTHON" app.py
} }
start_daemon() { start_daemon() {
if [ ! -f "$PID_FILE" ]; then
find_free_port
fi
if is_running; then if is_running; then
echo "服务已在运行 (PID: $(get_pid))" local running_port=$(get_running_port)
echo "服务已在运行 (PID: $(get_pid), 端口: $running_port)"
echo "访问地址: http://127.0.0.1:$running_port"
exit 1 exit 1
fi fi
echo "启动服务(后台守护模式)..." if [ -z "$PYTHON" ]; then
WEREAD_API_KEY="${WEREAD_API_KEY:-wrk-ZL_oy0woT5yKFaO1jn_15AAA}" \ echo "未找到可用的 Python 解释器"
nohup "$PYTHON" app.py > "$LOG_FILE" 2>&1 & exit 1
fi
echo "启动服务(后台守护模式,端口: $PORT)..."
PORT="$PORT" WEREAD_PORT="$PORT" nohup "$PYTHON" app.py > "$LOG_FILE" 2>&1 &
local pid=$! local pid=$!
echo "$pid" > "$PID_FILE" echo "$pid" > "$PID_FILE"
sleep 2 sleep 2
if kill -0 "$pid" 2>/dev/null; then if kill -0 "$pid" 2>/dev/null; then
echo "服务已启动 (PID: $pid)" echo "服务已启动 (PID: $pid)"
echo "访问: http://127.0.0.1:$PORT" echo "端口: $PORT"
echo "访问地址: http://127.0.0.1:$PORT"
echo "日志: $LOG_FILE" echo "日志: $LOG_FILE"
else else
echo "启动失败,请查看日志: $LOG_FILE" echo "启动失败,请查看日志: $LOG_FILE"
+16 -2
View File
@@ -748,6 +748,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
flex-wrap: wrap;
} }
.shelf-search { .shelf-search {
@@ -769,6 +770,7 @@ body {
.shelf-refresh-btn { .shelf-refresh-btn {
padding: 0 16px; padding: 0 16px;
height: 36px; height: 36px;
min-width: 112px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 13px; font-size: 13px;
@@ -781,7 +783,7 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
flex-shrink: 0; flex: 0 0 112px;
white-space: nowrap; white-space: nowrap;
} }
@@ -789,6 +791,15 @@ body {
display: inline-block; display: inline-block;
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
flex: 0 0 auto;
}
.shelf-refresh-btn .btn-text {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 4.5em;
line-height: 1;
} }
.shelf-refresh-btn:hover:not(:disabled) { .shelf-refresh-btn:hover:not(:disabled) {
@@ -801,7 +812,7 @@ body {
cursor: wait; cursor: wait;
} }
.shelf-refresh-btn.loading { .shelf-refresh-btn.is-loading {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
} }
@@ -2022,6 +2033,9 @@ body {
.shelf-search { .shelf-search {
width: 140px; width: 140px;
} }
.shelf-filters {
gap: 10px;
}
.shelf-categories { .shelf-categories {
gap: 6px; gap: 6px;
} }
+93
View File
@@ -0,0 +1,93 @@
: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);coloLine truncated
/* 阅读目标与内容页:与既有主题变量共用色彩。 */
: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:10pLine truncated
@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}
}
@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}
}
+784 -1238
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +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:'从具体的经济生活出发,理解政府与经济发展的关系。'}];
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']}
];
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:[]}
];
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'}
];
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'}
];
+145
View File
@@ -0,0 +1,145 @@
/* ==========================================================================
阅间 · 数据层(v2)
--------------------------------------------------------------------------
把后端 /api/v2/* 接口封装成前端可直接使用的数据结构。
设计稿(Design Demo)原本使用 demo-data.js 里的静态示例数据,
这里替换为真实接口调用,字段名与设计稿保持一致。
========================================================================== */
(function (global) {
'use strict';
const BASE = '/api/v2';
async function request(path, params, method = 'GET', body) {
const url = new URL(BASE + path, global.location.origin);
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value);
});
const response = await fetch(url.toString(), {
method,
headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
let payload = null;
try {
payload = await response.json();
} catch (error) {
payload = null;
}
if (!response.ok) {
const message = (payload && (payload.error || payload.detail)) || `请求失败(${response.status})`;
const err = new Error(message);
err.status = response.status;
err.payload = payload;
throw err;
}
return payload;
}
/* ------------------------------ 工具函数 ------------------------------ */
function durationText(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0));
const hours = Math.floor(total / 3600);
const minutes = Math.round((total % 3600) / 60);
return hours ? `${hours}小时${minutes}分钟` : `${minutes}分钟`;
}
function percentText(value) {
const num = Number(value) || 0;
return `${num}%`;
}
/* ------------------------------ 接口封装 ------------------------------ */
const WeApi = {
durationText,
percentText,
status() {
return request('/status');
},
goals() {
return request('/goals');
},
saveGoal(goal, editing = false) {
return request(editing ? `/goals/${encodeURIComponent(goal.id)}` : '/goals', null, editing ? 'PUT' : 'POST', goal);
},
deleteGoal(id) {
return request(`/goals/${encodeURIComponent(id)}`, null, 'DELETE');
},
importGoals(goals) {
return request('/goals/import', null, 'POST', { goals });
},
/**
* 书架数据
* @param {string} tab all|reading|done|wish
* @param {{keyword?:string, limit?:number, offset?:number, progress?:boolean}} options
*/
shelf(tab, options) {
const opts = options || {};
return request('/shelf', {
tab: tab || 'all',
keyword: opts.keyword || '',
limit: opts.limit,
offset: opts.offset,
progress: opts.progress ? 1 : 0,
});
},
dashboard(period) {
return request('/dashboard', { period: period || 'week' });
},
notebooks() {
return request('/notebooks');
},
/** bookId 传 'all' 时聚合笔记最多的前 limit 本 */
notes(bookId, limit) {
return request('/notes', { book_id: bookId, limit: limit });
},
book(bookId) {
return request('/book/' + encodeURIComponent(bookId));
},
reviews(bookId, type, count) {
return request(`/book/${encodeURIComponent(bookId)}/reviews`, { type: type || 0, count: count || 10 });
},
highlights(bookId, chapterUid) {
return request(`/book/${encodeURIComponent(bookId)}/highlights`, { chapter_uid: chapterUid || 0 });
},
underlineThoughts(bookId, chapterUid, range) {
return request(`/book/${encodeURIComponent(bookId)}/underline-thoughts`, {
chapter_uid: chapterUid,
range: range,
});
},
thought(reviewId) {
return request('/thought/' + encodeURIComponent(reviewId));
},
archives() {
return request('/archives');
},
recommend(maxIdx, refresh = false) {
return request('/recommend', { maxIdx: maxIdx || 0, refresh: refresh ? 1 : 0 });
},
search(keyword, scope) {
return request('/search', { keyword: keyword, scope: scope || 'books' });
},
};
global.WeApi = WeApi;
})(window);
+13 -208
View File
@@ -1,211 +1,16 @@
<!DOCTYPE html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>阅间 · 微信读书看板</title>
<title>我的读书数据</title> <link rel="stylesheet" href="/static/css/styles.css">
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<!-- 防闪烁:在 CSS 加载后、页面渲染前设置主题 -->
<script>
(function() {
var saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head> </head>
<body> <body>
<!-- 数据库连接告警横幅(MySQL 连接失败时显示) --> <svg style="display:none" aria-hidden="true"><defs>
<div id="dbAlertBanner" class="db-alert-banner" style="display:none;"> <symbol id="book" viewBox="0 0 24 24"><path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/></symbol><symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></symbol><symbol id="note" viewBox="0 0 24 24"><path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z"/></symbol><symbol id="search-icon" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></symbol><symbol id="chart" viewBox="0 0 24 24"><path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4"/></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="sync" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6"/></symbol><symbol id="flag" viewBox="0 0 24 24"><path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0"/></symbol>
<span class="db-alert-icon">⚠️</span> </defs></svg>
<span class="db-alert-text">MySQL 数据库连接失败,数据可能无法正常加载。请检查 MySQL 服务是否已启动。</span> <aside class="sidebar"><div class="logo"><div class="logo-mark"><svg><use href="#book"/></svg></div><div>阅间<small>READING SPACE</small></div></div><div class="navlabel">我的阅读空间</div><nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg><use href="#grid"/></svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg><use href="#book"/></svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg><use href="#flag"/></svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg><use href="#note"/></svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg><use href="#search-icon"/></svg><span>发现好书</span></button></nav><div class="sidebar-bottom"><div class="connection"><span class="dot"></span>微信读书 · 个人空间<small>让每一页,都留下痕迹</small></div><div class="profile"><div class="avatar">读</div><div>我的阅读空间<small>数据来自你的微信读书账号</small></div></div></div></aside>
<span class="db-alert-detail" id="dbAlertDetail"></span> <main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div><div class="top-actions"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘ K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg><use href="#moon"/></svg></button><div class="avatar">L</div></div></header><div class="demo-banner"><span><span class="dot"></span><span id="conn-state">正在连接微信读书…</span></span><span>阅读目标跨浏览器保存</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><span id="footer-state">数据来自微信读书个人账号</span></footer></main>
</div> <dialog id="detail" aria-label="内容详情"><div class="dialog-top"><h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail" aria-label="关闭详情">✕</button></div><div id="detail-content"></div></dialog>
<dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>保存后可在其他浏览器查看</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog>
<!-- 导航栏 --> <div id="toast" class="hidden" role="status"></div><script src="/static/js/weapi.js"></script><script src="/static/js/app.js"></script></body></html>
<nav class="navbar">
<div class="nav-container">
<div class="nav-logo" onclick="switchTab('overview')" style="cursor:pointer">
<span class="logo-icon">📖</span>
<span class="logo-text">我的读书数据</span>
</div>
<div class="nav-links">
<a class="nav-link active" data-tab="overview" onclick="switchTab('overview')">总览</a>
<a class="nav-link" data-tab="charts" onclick="switchTab('charts')">数据图表</a>
<a class="nav-link" data-tab="top-books" onclick="switchTab('top-books')">读书排行</a>
<a class="nav-link" data-tab="shelf" onclick="switchTab('shelf')">书架</a>
<a class="nav-link" data-tab="notes" onclick="switchTab('notes')">笔记</a>
<a class="nav-link" data-tab="discover" onclick="switchTab('discover')">推荐</a>
</div>
<div class="nav-right">
<span class="nav-update" id="fetchTime"></span>
<button class="theme-toggle" onclick="toggleTheme()" title="切换明亮/黑暗模式" aria-label="切换主题">
<span class="icon-sun">☀️</span>
<span class="icon-moon">🌙</span>
</button>
</div>
</div>
</nav>
<!-- 顶部常驻搜索框 -->
<div class="global-search-bar">
<div class="container">
<div class="global-search-wrapper">
<span class="global-search-icon">🔍</span>
<input type="text" id="globalSearchInput" class="global-search-input" placeholder="搜索图书..." onkeydown="if(event.key==='Enter')globalSearch()">
<button class="global-search-btn" onclick="globalSearch()">搜索</button>
</div>
</div>
</div>
<!-- ===== 主视图(标签页切换) ===== -->
<main id="mainView">
<!-- 总览标签页 -->
<section id="tab-overview" class="tab-panel active">
<div class="hero-section">
<div class="hero-bg"></div>
<div class="hero-content">
<h1 class="hero-title">阅读,是一场漫长的旅行</h1>
<p class="hero-subtitle" id="heroSubtitle">正在加载数据...</p>
<div class="hero-stats" id="heroStats"></div>
</div>
</div>
<section class="stats-cards-section">
<div class="container">
<div class="stats-grid" id="statsGrid"></div>
</div>
</section>
</section>
<!-- 数据图表标签页 -->
<section id="tab-charts" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">📊 阅读数据可视化</h2>
<div class="charts-grid">
<div class="chart-card large">
<h3 class="chart-title">年度阅读时长趋势</h3>
<div id="chart-yearly" class="chart-container"></div>
</div>
<div class="chart-card">
<h3 class="chart-title">24小时阅读时段分布</h3>
<div id="chart-hours" class="chart-container"></div>
</div>
<div class="chart-card">
<h3 class="chart-title">偏好阅读分类</h3>
<div id="chart-categories" class="chart-container"></div>
</div>
<div class="chart-card large">
<h3 class="chart-title">偏好作者 TOP 10</h3>
<div id="chart-authors" class="chart-container"></div>
</div>
</div>
</div>
</section>
<!-- 读书排行标签页 -->
<section id="tab-top-books" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">🏆 阅读时长排行</h2>
<div class="top-books-list" id="topBooksGrid"></div>
</div>
</section>
<!-- 书架标签页 -->
<section id="tab-shelf" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">📚 我的书架</h2>
<div class="shelf-controls">
<div class="shelf-filters">
<input type="text" id="shelfSearch" class="shelf-search" placeholder="搜索书名、作者...">
<label class="filter-checkbox">
<input type="checkbox" id="finishedOnly"> 仅看已读完
</label>
<button class="shelf-refresh-btn" id="shelfRefreshBtn" onclick="refreshShelf()" title="重新加载书架数据">
<span class="btn-icon">⟳</span>
<span class="btn-text">刷新</span>
</button>
</div>
<div class="shelf-info" id="shelfInfo"></div>
</div>
<div class="shelf-categories" id="shelfCategories"></div>
<div class="book-grid" id="bookGrid"></div>
<div class="pagination" id="pagination"></div>
</div>
</section>
<!-- 笔记标签页 -->
<section id="tab-notes" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">✍️ 笔记概览</h2>
<div class="notes-summary" id="notesSummary"></div>
<div class="notebooks-grid" id="notebooksGrid"></div>
</div>
</section>
<!-- 推荐标签页 -->
<section id="tab-discover" class="tab-panel">
<div class="container" style="padding-top:40px;padding-bottom:60px">
<h2 class="section-title">🔍 推荐发现</h2>
<div class="discover-tabs">
<button class="discover-tab active" id="tabRecommend" onclick="switchDiscoverTab('recommend')">为你推荐</button>
<button class="discover-tab" id="tabSearch" onclick="switchDiscoverTab('search')">搜索书籍</button>
</div>
<div id="recommendPanel" class="discover-panel">
<div class="discover-controls">
<button class="btn-primary" onclick="loadRecommend(0)">刷新推荐</button>
<button class="btn-secondary" onclick="loadMoreRecommend()">换一批</button>
</div>
<div class="discover-grid" id="recommendGrid"></div>
</div>
<div id="searchPanel" class="discover-panel" style="display:none">
<div class="search-box">
<input type="text" id="searchInput" class="search-input" placeholder="输入书名、作者或关键词..." onkeydown="if(event.key==='Enter')doSearch()">
<button class="btn-primary" onclick="doSearch()">搜索</button>
</div>
<div class="discover-grid" id="searchGrid"></div>
</div>
</div>
</section>
</main>
<!-- ===== 想法详情独立页面 ===== -->
<div id="thoughtsPage" class="thoughts-page" style="display:none">
<div class="thoughts-page-header">
<div class="container">
<button class="back-btn" onclick="closeThoughtsPage()">← 返回</button>
<div class="thoughts-book-header" id="thoughtsBookHeader"></div>
</div>
</div>
<div class="container thoughts-content" id="thoughtsContent">
<div class="loading"><div class="loading-spinner"></div></div>
</div>
</div>
<!-- 书籍详情弹窗 -->
<div class="modal-overlay" id="bookDetailModal" onclick="closeModal('bookDetailModal')">
<div class="modal-content book-detail-modal" onclick="event.stopPropagation()">
<span class="modal-close" onclick="closeModal('bookDetailModal')">&times;</span>
<div id="bookDetailBody" class="modal-body"></div>
</div>
</div>
<!-- 回到顶部按钮 -->
<button id="backToTop" class="back-to-top" onclick="scrollToTop()" title="回到顶部" aria-label="回到顶部">↑</button>
<!-- 页脚 -->
<footer class="footer">
<div class="container">
<p>数据来源:微信读书 · 数据存储:本地 MySQL · 更新时间:<span id="footerTime"></span></p>
<p class="footer-hint">点击书籍封面可在微信读书 App 中打开</p>
</div>
</footer>
<script src="/static/js/app.js"></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
function app() {
const nodes = new Map();
const node = selector => {
if (!nodes.has(selector)) nodes.set(selector, {
value: '', textContent: '', innerHTML: '', disabled: false,
classList: { add() {}, remove() {}, toggle() {} },
setAttribute() {}, addEventListener() {}, setCustomValidity() {}, reportValidity() {},
showModal() { this.open = true; }, close() { this.open = false; },
});
return nodes.get(selector);
};
const pending = () => new Promise(() => {});
const context = vm.createContext({
document: { querySelector: node, querySelectorAll: () => [], addEventListener() {}, documentElement: { dataset: {} } },
localStorage: { getItem: () => null },
WeApi: { status: pending, shelf: pending, dashboard: pending, goals: pending },
crypto: { randomUUID: () => 'test-goal' },
setTimeout() {}, clearTimeout() {}, console,
});
vm.runInContext(fs.readFileSync('static/js/app.js', 'utf8'), context);
return { context, node, run: code => vm.runInContext(code, context) };
}
test('目标书籍不在当前书架时仍保留总数,并可打开和编辑', () => {
const { run, node } = app();
run(`loadState.goals = 'ready';
books = [{id:'known',title:'已读书',status:'done'}];
goals = [{id:'g',name:'阅读',start:'2026-09-01',end:'2026-09-30',ids:['known','missing'],sources:[]}];
shelfData = {};`);
assert.equal(run('goalMetrics(goals[0]).total'), 2);
assert.equal(run('goalMetrics(goals[0]).percent'), 50);
run("openGoalDetail('g'); openGoalEditor('g');");
assert.match(node('#goal-picker').innerHTML, /状态待同步/);
assert.equal(run("selectedGoalBooks().includes('missing')"), true);
});
test('数据库保存失败时保留原目标和编辑草稿,允许重试', async () => {
const { context, run, node } = app();
run(`loadState.goals = 'ready';
shelfData = {}; books = [{id:'known',title:'一本书',status:'done'}];
goals = [{id:'g',name:'原名称',start:'2026-09-01',end:'2026-09-30',ids:['known'],sources:[]}];
openGoalEditor('g');`);
node('#goal-name').value = '新名称';
context.WeApi.saveGoal = async () => { throw new Error('数据库暂不可用'); };
await run('saveGoal({preventDefault(){}})');
assert.equal(run('goals[0].name'), '原名称');
assert.equal(node('#goal-editor').open, true);
assert.equal(node('#goal-name').value, '新名称');
assert.match(node('#goal-error').textContent, /数据库暂不可用/);
context.WeApi.saveGoal = async goal => ({ goal });
await run('saveGoal({preventDefault(){}})');
assert.equal(run('goals[0].name'), '新名称');
assert.equal(node('#goal-editor').open, false);
});
test('目标列表未完成加载时不打开编辑器', () => {
const { run, node } = app();
run(`page = 'overview';
loadState.goals = 'loading';
shelfData = {};
books = [{id:'known',title:'一本书',status:'done'}];
openGoalEditor(null, ['known'], []);`);
assert.notEqual(node('#goal-editor').open, true);
assert.equal(run('page'), 'goals');
assert.match(node('#page-content').innerHTML, /正在读取阅读目标/);
});
+164
View File
@@ -0,0 +1,164 @@
"""阅读目标接口回归测试;数据库故障通过连接边界模拟,不访问线上库。"""
import unittest
from datetime import date
from unittest.mock import MagicMock, patch
import pymysql
from flask import Flask
from api_v2 import v2_bp
class ReadingGoalsTest(unittest.TestCase):
def setUp(self):
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(v2_bp)
self.client = app.test_client()
self.conn = MagicMock()
self.cur = self.conn.cursor.return_value.__enter__.return_value
self.cur.fetchone.return_value = {"id": "goal-1"}
self.cur.fetchall.return_value = []
self.cur.rowcount = 1
self.connect = patch("api_v2.pymysql.connect", return_value=self.conn).start()
self.addCleanup(patch.stopall)
self.goal = {
"id": "goal-1", "name": " 九月阅读 ",
"start": "2026-09-01", "end": "2026-09-30",
"ids": ["unknown-book", "unknown-book", "123"],
"sources": ["科幻书单", "科幻书单"],
}
def test_create_normalizes_and_commits_unknown_members(self):
response = self.client.post("/api/v2/goals", json=self.goal)
self.assertEqual(response.status_code, 201)
expected = dict(self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"])
self.assertEqual(response.json, {"goal": expected})
self.conn.commit.assert_called_once()
self.conn.rollback.assert_not_called()
self.conn.close.assert_called_once()
def test_rejects_invalid_fields_without_writes(self):
invalid = [
{"id": 123}, {"id": ""}, {"id": "x" * 65},
{"name": " "}, {"name": "字" * 61},
{"start": "2026-02-29"}, {"start": "2026-9-01"},
{"start": "2026-09-31"}, {"end": "2026-08-31"},
{"ids": []}, {"ids": "123"}, {"ids": [123]},
{"ids": [""]}, {"ids": ["x" * 65]},
{"sources": "书单"}, {"sources": [123]},
]
for fields in invalid:
with self.subTest(fields=fields):
response = self.client.post("/api/v2/goals", json=dict(self.goal, **fields))
self.assertEqual(response.status_code, 400)
self.assertTrue(response.json["error"])
self.cur.execute.assert_not_called()
self.conn.commit.assert_not_called()
def test_invalid_json_and_body_shape_are_chinese_errors(self):
for body in ("{", "null", "[]"):
response = self.client.post("/api/v2/goals", data=body, content_type="application/json")
self.assertEqual(response.status_code, 400)
self.assertTrue(any("\u4e00" <= ch <= "\u9fff" for ch in response.json["error"]))
def test_list_preserves_unknown_members_and_formats_dates(self):
self.cur.fetchall.return_value = [
{"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1),
"end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "unknown-book"},
{"id": "goal-1", "name": "九月阅读", "start_date": date(2026, 9, 1),
"end_date": date(2026, 9, 30), "sources": '["科幻书单"]', "book_id": "123"},
]
response = self.client.get("/api/v2/goals")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"goals": [dict(
self.goal, name="九月阅读", ids=["unknown-book", "123"], sources=["科幻书单"]
)]})
def test_create_duplicate_returns_conflict(self):
self.cur.execute.side_effect = pymysql.err.IntegrityError(1062, "Duplicate entry")
response = self.client.post("/api/v2/goals", json=self.goal)
self.assertEqual(response.status_code, 409)
self.conn.commit.assert_not_called()
self.conn.rollback.assert_called_once()
def test_update_and_delete_missing_goal_return_not_found(self):
self.cur.fetchone.return_value = None
self.cur.rowcount = 0
for method in (self.client.put, self.client.delete):
response = method("/api/v2/goals/missing", json=dict(self.goal, id="missing"))
self.assertEqual(response.status_code, 404)
self.conn.commit.assert_not_called()
def test_update_rejects_mismatched_id(self):
response = self.client.put("/api/v2/goals/other", json=self.goal)
self.assertEqual(response.status_code, 400)
self.cur.execute.assert_not_called()
def test_member_write_failure_rolls_back_parent_change(self):
self.cur.executemany.side_effect = pymysql.err.OperationalError(2013, "Lost connection")
for method, path in ((self.client.post, "/api/v2/goals"),
(self.client.put, "/api/v2/goals/goal-1")):
with self.subTest(path=path):
self.conn.reset_mock()
response = method(path, json=self.goal)
self.assertEqual(response.status_code, 503)
self.conn.rollback.assert_called_once()
self.conn.commit.assert_not_called()
def test_import_validates_entire_batch_before_writes(self):
response = self.client.post("/api/v2/goals/import", json={
"goals": [self.goal, dict(self.goal, id="bad", ids=[])]
})
self.assertEqual(response.status_code, 400)
self.cur.execute.assert_not_called()
def test_import_skips_existing_and_duplicate_batch_ids(self):
# 第一个 ID 已存在;第二个 ID 首次插入;批内重复项不再写入。
counts = iter([0, 1])
def execute(sql, params=None):
self.cur.rowcount = next(counts)
return self.cur.rowcount
self.cur.execute.side_effect = execute
response = self.client.post("/api/v2/goals/import", json={"goals": [
self.goal, dict(self.goal, id="new"), dict(self.goal, id="new")
]})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {"imported": 1})
self.cur.executemany.assert_called_once()
self.conn.commit.assert_called_once()
def test_import_second_member_failure_rolls_back_whole_batch(self):
self.cur.executemany.side_effect = [None, pymysql.err.OperationalError(2013, "Lost connection")]
response = self.client.post("/api/v2/goals/import", json={
"goals": [self.goal, dict(self.goal, id="second")]
})
self.assertEqual(response.status_code, 503)
self.conn.rollback.assert_called_once()
self.conn.commit.assert_not_called()
def test_database_failures_never_return_successful_empty_data(self):
self.connect.side_effect = pymysql.err.OperationalError(2003, "Unavailable")
for method, path, body in (
(self.client.get, "/api/v2/goals", None),
(self.client.post, "/api/v2/goals", self.goal),
(self.client.put, "/api/v2/goals/goal-1", self.goal),
(self.client.delete, "/api/v2/goals/goal-1", None),
(self.client.post, "/api/v2/goals/import", {"goals": [self.goal]}),
):
with self.subTest(path=path, method=method):
response = method(path, json=body)
self.assertEqual(response.status_code, 503)
self.assertIn("数据库", response.json["error"])
def test_commit_and_rollback_connection_failures_still_return_503(self):
self.conn.commit.side_effect = pymysql.err.OperationalError(2013, "Lost connection")
self.conn.rollback.side_effect = pymysql.err.InterfaceError(0, "Connection closed")
response = self.client.post("/api/v2/goals", json=self.goal)
self.assertEqual(response.status_code, 503)
self.conn.close.assert_called_once()
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
"""显式设置 WEREAD_TEST_MYSQL=1 后,验证已迁移数据库;仅清理本测试生成的 ID。"""
import os
import unittest
from uuid import uuid4
from unittest.mock import patch
import pymysql
from flask import Flask
import api_v2
@unittest.skipUnless(os.environ.get('WEREAD_TEST_MYSQL') == '1', '需显式启用 MySQL 集成测试')
class GoalsMySQLTest(unittest.TestCase):
def test_persistence_import_and_atomic_rollback(self):
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v2.v2_bp)
client = app.test_client()
ids = [f'test-{uuid4()}' for _ in range(4)]
goal = {'id': ids[0], 'name': '数据库验收测试', 'start': '2026-09-01',
'end': '2026-09-30', 'ids': ['missing-book', '123', '123'], 'sources': ['书单']}
try:
response = client.post('/api/v2/goals', json=goal)
self.assertEqual(response.status_code, 201, response.json)
self.assertEqual(len(response.json['goal']['ids']), 2)
# 每个请求使用新的连接,检验提交后的持久化,而不是连接内未提交的内容。
saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']}
self.assertEqual(set(saved[ids[0]]['ids']), {'missing-book', '123'})
changed = dict(goal, name='修改后的测试', ids=['missing-book'])
self.assertEqual(client.put(f'/api/v2/goals/{ids[0]}', json=changed).status_code, 200)
imported = dict(goal, id=ids[1])
for count in (1, 0):
response = client.post('/api/v2/goals/import', json={'goals': [goal, imported, imported]})
self.assertEqual(response.json, {'imported': count})
saved = {g['id']: g for g in client.get('/api/v2/goals').json['goals']}
self.assertEqual(saved[ids[0]]['name'], '修改后的测试')
self.assertEqual(saved[ids[0]]['ids'], ['missing-book'])
insert = api_v2.insert_goal
def fail_second(cur, item, skip_existing=False):
if item['id'] == ids[3]:
raise pymysql.OperationalError(2013, '模拟批次中断')
return insert(cur, item, skip_existing)
with patch('api_v2.insert_goal', side_effect=fail_second):
response = client.post('/api/v2/goals/import', json={
'goals': [dict(goal, id=ids[2]), dict(goal, id=ids[3])]})
self.assertEqual(response.status_code, 503)
saved = {g['id'] for g in client.get('/api/v2/goals').json['goals']}
self.assertNotIn(ids[2], saved)
self.assertNotIn(ids[3], saved)
self.assertEqual(client.delete(f'/api/v2/goals/{ids[0]}').status_code, 200)
with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
cur.execute('SELECT COUNT(*) AS n FROM reading_goal_books WHERE goal_id=%s', (ids[0],))
self.assertEqual(cur.fetchone()['n'], 0)
finally:
with pymysql.connect(**api_v2.MYSQL_CONFIG) as conn:
with conn.cursor() as cur:
cur.executemany('DELETE FROM reading_goals WHERE id=%s', [(goal_id,) for goal_id in ids])
conn.commit()
if __name__ == '__main__':
unittest.main()
+273
View File
@@ -0,0 +1,273 @@
const $ = selector => document.querySelector(selector);
const esc = value => String(value).replace(/[&<>"']/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const findBook = id => books.find(book => book.id === id);
const uniqueIds = ids => [...new Set(ids)].filter(id => findBook(id));
const today = () => new Date().toLocaleDateString('sv-SE');
const storageKey = 'weread-demo-goals-v2';
let page = 'overview', shelfTab = 'reading', discoverTab = 'books', searchQuery = '', period = 'week';
let noteBook = 'all', noteType = 'all', goalFilter = 'all', dataState = 'ready';
let detailBook = null, bookTab = 'info', reviewFilter = '全部', selectedChapter = 'all';
let editingGoalId = null, draftIds = new Set(), draftSources = [], goals = initialGoals.map(goal => ({...goal, ids:[...goal.ids], sources:[...goal.sources]}));
let toastTimer, storageWarning = '';
// 本地数据损坏时保留原存储并恢复示例;保存前仍要求用户通过有效表单提交。
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const parsed = JSON.parse(saved);
if (!Array.isArray(parsed) || !parsed.every(goal => typeof goal.id === 'string' && typeof goal.name === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(goal.start) && /^\d{4}-\d{2}-\d{2}$/.test(goal.end) && goal.start <= goal.end && Array.isArray(goal.ids) && Array.isArray(goal.sources))) throw new Error('invalid');
goals = parsed.map(goal => ({...goal,ids:uniqueIds(goal.ids)}));
}
} catch { storageWarning = '本地目标暂时无法读取,已显示示例目标;原存储未修改。'; }
function toast(message) {
clearTimeout(toastTimer); $('#toast').textContent = message; $('#toast').classList.remove('hidden');
toastTimer = setTimeout(() => $('#toast').classList.add('hidden'), 3500);
}
function persistGoals(next) {
try { localStorage.setItem(storageKey, JSON.stringify(next)); }
catch { $('#goal-error').textContent = '浏览器无法保存数据,请允许本地存储后重试。'; return false; }
goals = next; return true;
}
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
$('#theme').innerHTML = `<svg><use href="#${theme === 'dark' ? 'sun' : 'moon'}"/></svg>`;
$('#theme').setAttribute('aria-label', `切换至${theme === 'dark' ? '亮' : '暗'}色主题`);
}
try { setTheme(localStorage.getItem('weread-demo-theme') === 'dark' ? 'dark' : 'light'); } catch { setTheme('light'); }
$('#theme').onclick = () => {
const theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; setTheme(theme);
try { localStorage.setItem('weread-demo-theme', theme); } catch { toast('主题已切换,本次浏览有效。'); }
};
function cover(book, mini = false) {
if (mini) return `<div class="mini-cover ${book.cover}" aria-hidden="true"><b>${esc(book.title)}</b><small>${esc(book.author)}</small></div>`;
return `<div class="cover ${book.cover}"><span class="tiny">${book.en}</span><span class="title">${book.short.replaceAll('\n','<br>')}</span><span class="author">${book.author}</span><span class="publisher">READING SPACE · 示意封面</span></div>`;
}
function bookCard(book) {
return `<button class="book" data-action="book" data-id="${book.id}" aria-label="查看${book.title}详情">${cover(book)}<h3>${book.title}</h3><p>${book.author}</p><div class="progress" style="--progress:${book.progress}%"><i></i></div><div class="book-meta"><span>${book.status === 'done' ? '已读完' : book.status === 'wish' ? '想读' : `已读 ${book.progress}%`}</span><span>评分 ${Math.round(Number(book.score)*10)}/100</span></div></button>`;
}
function bookRow(book, source = '') {
return `<div class="book-row">${cover(book,true)}<div class="book-row-text"><b>${book.title}</b><p>${book.author}${source ? ' · '+esc(source) : ''}</p><div class="progress" style="--progress:${book.progress}%"><i></i></div></div><span class="book-row-status">${book.status === 'done' ? '已读完' : `${book.progress}%`}</span><button class="text-button" data-action="book" data-id="${book.id}" aria-label="查看${book.title}详情">详情 ↗</button></div>`;
}
function welcome(title, subtitle, action = '') {
return `<section class="welcome"><div><div class="eyebrow">A LITTLE READING, EVERY DAY</div><h1>${title}</h1><p>${subtitle}</p></div><div class="page-actions">${action}</div></section>`;
}
const pageNames = {overview:'阅读概览',shelf:'我的书架',goals:'阅读目标',notes:'笔记与划线',discover:'发现好书'};
function navigate(next) {
page = next; $('#crumb').textContent = pageNames[page];
document.querySelectorAll('[data-page]').forEach(button => {button.classList.toggle('active',button.dataset.page === page);button.setAttribute('aria-current',button.dataset.page === page ? 'page' : 'false');});
renderPage();
}
function renderPage() {
const views = {overview:renderOverview,shelf:renderShelf,goals:renderGoals,notes:renderNotes,discover:renderDiscover};
$('#page-content').innerHTML = views[page]();
}
function tabs(items, selected, action) {
return `<div class="type-chips">${items.map(([id,label]) => `<button class="${selected === id ? 'active':''}" aria-pressed="${selected === id}" data-action="${action}" data-id="${id}">${label}</button>`).join('')}</div>`;
}
// 已读状态与目标日期无关;先按书籍 ID 去重,再计算完成率。
function goalMetrics(goal, date = today()) {
const ids = uniqueIds(goal.ids), done = ids.filter(id => findBook(id).status === 'done').length;
const status = ids.length && done === ids.length ? '已完成' : date < goal.start ? '未开始' : date > goal.end ? '已到期' : '进行中';
return {total:ids.length,done,percent:ids.length ? Math.round(done/ids.length*100):0,status};
}
function goalCard(goal) {
const result = goalMetrics(goal);
return `<article class="card goal-card"><div class="section-head"><span class="pill ${result.status === '已完成' ? 'green' : result.status === '已到期' ? 'amber':''}">${result.status}</span><button class="text-button" data-action="edit-goal" data-id="${esc(goal.id)}" aria-label="编辑${esc(goal.name)}">编辑 ↗</button></div><h3>${esc(goal.name)}</h3><div class="goal-date">${goal.start} — ${goal.end}</div><div class="cover-stack">${goal.ids.slice(0,4).map(id=>cover(findBook(id),true)).join('')}${result.total>4?`<span class="stack-more">+ ${result.total-4} 本</span>`:''}</div><div class="goal-progress"><span><strong>${result.done}</strong> / ${result.total} 本已读完</span><span>${result.percent}%</span></div><div class="progress" style="--progress:${result.percent}%"><i></i></div><div class="goal-card-footer"><small>${goal.sources.length ? `来自 ${goal.sources.length} 个书单 · 可混合单书` : '按本选书 · 自己定义节奏'}</small><button data-action="goal-detail" data-id="${esc(goal.id)}">查看清单 →</button></div></article>`;
}
function renderGoals() {
const list = goals.filter(goal => goalFilter === 'all' || (goalFilter === 'complete' ? goalMetrics(goal).status === '已完成' : goalMetrics(goal).status !== '已完成'));
return welcome('把想读,变成读过','几本书、一份书单,或一段想留给阅读的时间。', '<button class="primary" data-action="new-goal">+ 新建阅读目标</button>')+
`<div class="goal-intro"><div><div class="eyebrow">YOUR NEXT CHAPTER</div><h2>目标不必宏大,开始就很好。</h2><p>每份清单都有自己的节奏。已经读完的书,也会成为你完成目标的一部分。</p><div class="metrics-row"><span><strong>${goals.length}</strong>个目标</span><span><strong>${goals.filter(g=>goalMetrics(g).status==='已完成').length}</strong>个已完成</span><span><strong>${uniqueIds(goals.flatMap(g=>g.ids)).length}</strong>本不同的书</span></div></div><span class="symbol">❧</span></div>`+
tabs([['all','全部目标'],['active','未完成'],['complete','已完成']],goalFilter,'goal-filter')+`<div class="goals-grid">${list.map(goalCard).join('') || '<div class="card empty">这里还没有目标,创建一份属于你的阅读清单。</div>'}</div>`;
}
function summaryGoals() {
return `<section class="card panel summary-goals"><div class="section-head"><h2>我的阅读目标</h2><button class="text-button" data-action="page" data-id="goals">全部 ↗</button></div>${goals.slice(0,3).map(goal=>{const m=goalMetrics(goal);return `<button class="goal-summary-row" data-action="goal-detail" data-id="${esc(goal.id)}"><strong>${esc(goal.name)}</strong><small>${m.done} / ${m.total} 本已读完 · ${m.status}</small><div class="progress" style="--progress:${m.percent}%"><i></i></div></button>`;}).join('')}<button class="text-button section-space" data-action="new-goal">+ 新建阅读目标</button></section>`;
}
function renderOverview() {
const values = {week:[42,65,33,57,86,71,48],month:[48,54,65,38,70,52,64,75,68,42,46],year:[8,11,14,9,18,15,12,14,27.7],overall:[75,96,128.7]}[period];
const labels = {week:['一','二','三','四','五','六','日'],month:['1日','2日','3日','4日','5日','6日','7日','8日','9日','10日','11日'],year:['1月','2月','3月','4月','5月','6月','7月','8月','9月'],overall:['2024','2025','2026']}[period];
const max = Math.ceil(Math.max(...values)/10)*10;
const totalMinutes = values.reduce((a,b)=>a+b,0)*(period==='year'||period==='overall'?60:1);
const duration = `${Math.floor(totalMinutes/60)}小时${Math.round(totalMinutes%60)}分钟`;
const days = {week:7,month:11,year:132,overall:412}[period];
const naturalDays = {week:7,month:11,year:254,overall:985}[period];
const units = period==='year'||period==='overall'?'时':'分';
const stats = [['阅读 / 收听时长',duration,'当前所选周期','clock'],['有效阅读天数',`${days}<small>天</small>`,'单日满 1 分钟计一天','flag'],['自然日均阅读',`${Math.round(totalMinutes/naturalDays)}<small>分钟</small>`,'按周期自然日计算','chart'],['笔记总数','10<small>条</small>','划线 2 · 想法 2 · 书签 6','note']];
return welcome('在字里行间,遇见自己','阅读、目标与灵感,在这里慢慢积累。','<button class="primary" data-action="new-goal">+ 新建目标</button>')+
`<div class="toolbar">${tabs([['week','本周'],['month','本月'],['year','本年'],['overall','总计']],period,'period')}<span class="hint">统计示例 · ${period==='overall'?'2024—2026':'2026 年'}</span></div><section class="stats">${stats.map(([label,value,hint,icon])=>`<article class="card stat"><div class="stat-label">${label}<svg><use href="#${icon}"/></svg></div><div class="stat-value compact-value">${value}</div><div class="stat-foot">${hint}</div></article>`).join('')}</section>
<div class="columns"><div><section class="card trend"><div class="section-head"><h2>阅读的日常 <small>每一天,都算数</small></h2><span class="hint">${period==='year'?'按月':period==='overall'?'按年':'按日'}</span></div><div class="trend-total">${duration}<small>阅读与收听合计</small></div><div class="chart"><div class="gridlines">${[max,Math.round(max*2/3),Math.round(max/3),0].map(n=>`<i data-label="${n}${n?units:''}"></i>`).join('')}</div><div class="bars">${values.map((v,i)=>`<div class="bar" style="--h:${v/max*100}%" data-tip="${v}${units}" role="img" aria-label="${labels[i]} ${v}${units}"><span>${labels[i]}</span></div>`).join('')}</div></div></section><section class="card shelf"><div class="section-head"><h2>继续阅读</h2><button class="text-button" data-action="page" data-id="shelf">全部书架 ↗</button></div><div class="books">${books.filter(b=>b.status==='reading').map(bookCard).join('')}</div></section></div><aside class="rightcol">${summaryGoals()}<section class="quote"><div class="eyebrow">✦ 留给今天的一句话</div><p>不必追赶每一页,<br>只需为自己翻开一本书。</p><small>— 阅间 · 原创阅读手记</small></section></aside></div>
<div class="insight-grid"><section class="card panel insight"><h3>读得最多</h3>${books.slice(0,3).map((b,i)=>`<div class="insight-line"><b>${i+1}. ${b.title}</b><span>${[2,1,1][i]}小时${[12,35,6][i]}分钟</span></div>`).join('')}</section><section class="card panel insight"><h3>偏爱的阅读方向</h3>${[['文学',80],['心理学',60],['科幻',40]].map(([label,val])=>`<div class="insight-line"><b>${label}</b><span>相对偏好 ${val}</span></div><div class="progress" style="--progress:${val}%"><i></i></div>`).join('')}<p class="hint section-space">相对偏好权重,不是阅读占比</p></section><section class="card panel insight"><h3>留给自己的时段</h3><div class="hour-bars">${Array.from({length:24},(_,i)=>`<i style="--height:${[10,20,15,12,20,35,28,24,12,10,16,20,30,35,40,65,95,85,40,15,6,4,3,5][i]}%"></i>`).join('')}</div><div class="insight-line"><span>06:00</span><span>18:00</span><span>次日 05:00</span></div><p class="hint">偏好夜间阅读 · 示例时段分布</p></section></div>
<details class="insight-detail card panel"><summary>更多阅读画像 · 作者、听书、勋章与年度回顾</summary><div class="insight-grid"><div><h3>偏好作者与出版方</h3><div class="insight-line"><b>刘慈欣</b><span>3 本 · 5小时30分钟</span></div><div class="insight-line"><b>示例出版社</b><span>4 本</span></div><div class="insight-line"><b>示例版权方</b><span>3 本</span></div></div><div><h3>阅读与收听</h3><div class="insight-line"><b>文字阅读</b><span>80%</span></div><div class="progress" style="--progress:80%"><i></i></div><p class="hint section-space">示例分布;正式接入只在接口返回时展示。</p></div><div><h3>阅读足迹</h3><div class="insight-line"><b>好友排行</b><span>本周第 3 · 示例</span></div><div class="insight-line"><b>勋章</b><span>初读 · 笔记 · 坚持</span></div><button class="text-button" data-action="year-report">年度回顾示意 →</button></div></div></details>`;
}
function listCard(list, index) {
return `<button class="card list-card" data-action="list" data-id="${list.id}"><div class="list-art">${list.ids.slice(0,3).map(id=>cover(findBook(id),true)).join('')}</div><h3><span class="list-index">${String(index+1).padStart(2,'0')}</span>${list.name}</h3><p>${list.description}</p><small>${list.ids.length} 本书 · ${list.source}</small></button>`;
}
function renderLists(lists) { return `<div class="list-grid">${lists.map(listCard).join('') || '<div class="empty">没有匹配的书单,换个关键词试试。</div>'}</div>`; }
function renderShelf() {
let content;
if (shelfTab==='lists') content=renderLists(booklists.filter(l=>l.source==='我的书单'));
else if (shelfTab==='audio') content=`<article class="card media-card"><div class="media-icon">♫</div><div><h3>晚间听书 · 文学选读</h3><p>示例演播者 · 24 集 · 内容已完结</p><p class="hint">内容完结不代表已听完;当前不纳入按本阅读目标。</p></div></article>`;
else if(shelfTab==='articles') content=`<article class="card media-card"><div class="media-icon">▤</div><div><h3>文章收藏</h3><p>这是书架中的收藏入口。</p><p class="hint">当前书架接口不提供入口内文章列表;可在发现页搜索文章。</p><button class="text-button" data-action="article-search">去搜索文章 →</button></div></article>`;
else content=`<div class="card panel"><div class="books full-books">${books.filter(b=>shelfTab==='all'||b.status===shelfTab).map(bookCard).join('')}</div></div>`;
return welcome('我的书架','8 本电子书 · 1 个有声专辑 · 1 个文章收藏入口,共 10 个书架条目。')+tabs([['all','全部电子书 8'],['reading','在读 4'],['done','已读完 2'],['wish','想读 2'],['lists','我的书单 2'],['audio','有声书 1'],['articles','文章收藏']],shelfTab,'shelf-tab')+content;
}
function openList(id) {
const list=booklists.find(l=>l.id===id);
showDetail('书单详情',`<div class="discovery-hero"><div class="eyebrow">A COLLECTION OF GOOD BOOKS</div><h2>${list.name}</h2><p>${list.description}</p><p class="source-tag">${list.source} · ${list.ids.length} 本 · 示例成员</p></div><div class="row-list">${list.ids.map(id=>bookRow(findBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="add-list" data-id="${id}">加入阅读目标</button></div>${list.source.startsWith('公开')?'<p class="hint section-space">公开书单成员为设计示意,完整成员获取方式待真实接口验证。</p>':''}`);
}
function showDetail(title, html) { $('#detail-title').textContent=title;$('#detail-content').innerHTML=html;if(!$('#detail').open)$('#detail').showModal(); }
function openGoalDetail(id) {
const goal=goals.find(g=>g.id===id), m=goalMetrics(goal);
showDetail('阅读目标',`<div class="section-head"><span class="pill green">${m.status}</span><span class="hint">${goal.start} — ${goal.end}</span></div><h2>${esc(goal.name)}</h2><div class="metrics-row"><span><strong>${m.done} / ${m.total}</strong>本已读完</span><span><strong>${m.percent}%</strong>已完成</span></div><p class="hint">已读完直接计入完成,不受目标日期影响。</p>${goal.sources.length?`<p class="source-tag">加入过的书单:${goal.sources.map(esc).join('、')} · 保存时的书目</p>`:''}<div class="row-list">${goal.ids.map(id=>bookRow(findBook(id))).join('')}</div><div class="goal-actions"><button class="primary" data-action="edit-goal" data-id="${esc(id)}">编辑目标与书目</button></div>`);
}
function openGoalEditor(id=null, prefill=[], sources=[]) {
$('#detail').close(); editingGoalId=id;
const goal=id?goals.find(g=>g.id===id):null;
draftIds=new Set(goal?goal.ids:prefill);draftSources=[...(goal?goal.sources:sources)];
$('#editor-title').textContent=goal?'编辑阅读目标':'新建阅读目标';$('#goal-name').value=goal?goal.name:'';
$('#goal-start').value=goal?goal.start:today();$('#goal-end').value=goal?goal.end:`${today().slice(0,4)}-12-31`;
$('#goal-end').min=$('#goal-start').value;$('#goal-end').setCustomValidity('');$('#goal-name').setCustomValidity('');
$('#picker-search').value='';$('#goal-error').textContent='';renderPicker();$('#goal-editor').showModal();
}
function selectedGoalBooks() { return uniqueIds([...draftIds]); }
function renderPicker() {
const query=$('#picker-search').value.trim().toLowerCase();
const list=books.filter(b=>`${b.title}${b.author}`.toLowerCase().includes(query));
$('#selection-count').textContent=`已选 ${draftIds.size} 本 · ${[...draftIds].filter(id=>findBook(id).status==='done').length} 本已读完`;
$('#goal-picker').innerHTML=`<p class="hint">快捷加入书单当前书目</p><div class="picker-lists">${booklists.filter(l=>!query||l.name.includes(query)||l.ids.some(id=>list.some(b=>b.id===id))).map(l=>`<button type="button" data-action="pick-list" data-id="${l.id}" class="${l.ids.every(id=>draftIds.has(id))?'active':''}">${l.ids.every(id=>draftIds.has(id))?'✓':'+'} ${l.name} · ${l.ids.length} 本</button>`).join('')}</div><div class="picker-list">${list.map(b=>`<label class="pick-row"><input type="checkbox" data-pick-book="${b.id}" ${draftIds.has(b.id)?'checked':''}><span><b>${b.title}</b><small>${b.author}</small></span><span class="pill ${b.status==='done'?'green':''}">${b.status==='done'?'已读完':b.status==='wish'?'想读':b.progress+'%'}</span></label>`).join('')||'<div class="empty">没有匹配的书籍</div>'}</div>`;
}
function saveGoal(event) {
event.preventDefault();const name=$('#goal-name').value.trim(),start=$('#goal-start').value,end=$('#goal-end').value,ids=selectedGoalBooks();
if(!name){$('#goal-name').setCustomValidity('请输入目标名称');$('#goal-name').reportValidity();return;}
if(end<start){$('#goal-error').textContent='结束日期不能早于开始日期。';return;}
if(!ids.length){$('#goal-error').textContent='请至少选择一本书,或加入一个书单。';return;}
const goal={id:editingGoalId||crypto.randomUUID(),name,start,end,ids,sources:[...new Set(draftSources)]};
const next=editingGoalId?goals.map(g=>g.id===editingGoalId?goal:g):[goal,...goals];
if(!persistGoals(next))return;
$('#goal-editor').close();navigate('goals');toast('阅读目标已保存,已读完的书已计入完成。');
}
function chooseTarget(ids,sources=[]) {
showDetail('加入阅读目标',`<p class="detail-subtitle">选择已有目标,或为这些书新建目标。重复书籍只加入一次。</p><div class="row-list">${goals.map(g=>`<div class="book-row"><div class="book-row-text"><b>${esc(g.name)}</b><p>${g.start} — ${g.end} · ${g.ids.length} 本</p></div><button class="secondary" data-action="append-goal" data-id="${esc(g.id)}">加入</button></div>`).join('')}</div><div class="goal-actions"><button class="primary" data-action="new-with-selection">+ 新建目标</button></div>`);
pendingBooks=ids;pendingSources=sources;
}
let pendingBooks=[],pendingSources=[];
function appendGoal(id) {
const next=goals.map(g=>g.id===id?{...g,ids:uniqueIds([...g.ids,...pendingBooks]),sources:[...new Set([...g.sources,...pendingSources])]}:g);
try { localStorage.setItem(storageKey,JSON.stringify(next)); } catch { toast('无法保存到浏览器,请允许本地存储后重试。');return; }
goals=next;renderPage();openGoalDetail(id);toast('已加入目标,重复书籍已自动去重。');
}
function renderDiscover() {
const options=[['books','书籍'],['lists','书单'],['recommend','为你推荐'],['all','综合'],['author','作者'],['novel','网文'],['audio','听书'],['fulltext','全文'],['account','公众号'],['article','文章']];
const matched=books.filter(b=>`${b.title}${b.author}`.toLowerCase().includes(searchQuery.toLowerCase()));
let content='';
if(dataState!=='ready') {
const copy={loading:['正在寻找好书','加载状态示意,点击下方按钮查看结果。'],empty:['暂时没有找到','换个关键词,或看看为你推荐。'],error:['暂时无法获取数据','连接失败示意,你的阅读目标仍保存在本地。']}[dataState];
content=`<div class="card status-box"><div class="eyebrow">${dataState==='loading'?'LOADING':dataState==='error'?'TRY AGAIN':'NOT FOUND'}</div><h3>${copy[0]}</h3><p>${copy[1]}</p><button class="secondary" data-action="retry">${dataState==='error'?'重试':'查看演示结果'}</button></div>`;
} else if(discoverTab==='lists') content=renderLists(booklists.filter(l=>`${l.name}${l.description}`.includes(searchQuery)));
else if(discoverTab==='books'||discoverTab==='recommend') content=`${discoverTab==='recommend'?'<div class="discovery-hero"><div class="eyebrow">PICKED FOR YOUR NEXT CHAPTER</div><h2>读过的书,会带你遇见下一本。</h2><p>根据你对文学与心理学的兴趣推荐 · 推荐理由为示例</p></div>':''}<div class="card panel"><div class="books full-books">${(discoverTab==='recommend'?matched.filter(b=>b.status==='wish'||b.id==='b0'):matched).map(bookCard).join('')||'<div class="empty">没有找到相关书籍,试试其他书名或作者。</div>'}</div></div>`;
else if(discoverTab==='author') content=`<div class="card panel">${[...new Set(matched.map(b=>b.author))].map(name=>`<div class="book-row"><div class="avatar">${name.slice(0,1)}</div><div class="book-row-text"><b>${name}</b><p>作者结果 · 示例</p></div><button class="text-button" data-action="author-books" data-name="${esc(name)}">查看书籍 →</button></div>`).join('')||'<div class="empty">没有匹配的作者。</div>'}</div>`;
else if(discoverTab==='all') content=`<div class="section-head"><h2>书籍</h2></div><div class="card panel"><div class="books">${matched.slice(0,4).map(bookCard).join('')||'<div class="empty">没有匹配的书籍。</div>'}</div></div><div class="section-head section-space"><h2>书单</h2></div>${renderLists(booklists.filter(l=>l.name.includes(searchQuery)))}`;
else {
const examples={novel:['星河来信','网文小说 · 示例作品','连载中 · 128 章'],audio:['夜晚的文学课','有声专辑 · 示例演播者','24 集 · 内容已完结'],fulltext:['关于阅读的一段文字','全文搜索 · 原创示例文本','阅读让日常拥有了更宽阔的边界。'],account:['一页书房','公众号 · 示例账号','记录阅读、日常与思考'],article:['如何为自己留一点阅读时间','文章 · 原创示例','2026-09-11 · 一页书房']}[discoverTab];
const matches=!searchQuery||examples.join('').includes(searchQuery);
content=matches?`<div class="card media-card"><div class="media-icon">${discoverTab==='audio'?'♫':'▤'}</div><div><h3>${examples[0]}</h3><p>${examples[1]}</p><p>${examples[2]}</p><button class="text-button" data-action="search-example" data-id="${discoverTab}">查看结果示意 →</button></div></div>`:'<div class="card empty">没有匹配的演示内容,可清空关键词查看该类型示意。</div>';
}
return welcome('下一本,遇见什么?','寻找一本书,也发现一份恰好适合你的书单。')+
`<div class="toolbar"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="discover-search" aria-label="搜索发现内容" placeholder="书名、书单或关键词…" value="${esc(searchQuery)}"></label><label class="hint">预览状态 <select id="data-state" aria-label="预览数据状态">${[['ready','正常'],['loading','加载中'],['empty','空结果'],['error','失败']].map(([id,label])=>`<option value="${id}" ${dataState===id?'selected':''}>${label}</option>`).join('')}</select></label></div>${tabs(options,discoverTab,'discover-tab')}<div id="discover-results">${content}</div><p class="hint section-space">搜索、推荐、书单均为设计样例,未请求微信读书服务。</p>`;
}
function renderNotes() {
const list=personalNotes.filter(note=>(noteBook==='all'||note.bookId===noteBook)&&(noteType==='all'||note.type===noteType));
const counts=personalNotes.filter(note=>noteBook==='all'||note.bookId===noteBook);
const bookmarks=noteBook==='all'?6:noteBook==='b0'?3:noteBook==='b1'?2:noteBook==='b2'?1:0;
return welcome('把读过的,留在心里','划线、想法与每一次停顿,都有自己的意义。','<button class="secondary" data-action="export-notes">↓ 导出当前书目笔记</button>')+
`<div class="notebook-grid"><aside class="card panel notebook-menu"><button data-action="notebook" data-id="all" class="${noteBook==='all'?'active':''}"><b>全部笔记本</b><small>4 本书 · 10 条笔记</small></button>${books.slice(0,4).map((b,i)=>`<button data-action="notebook" data-id="${b.id}" class="${noteBook===b.id?'active':''}"><b>${b.title}</b><small>${[4,3,2,1][i]} 条 · 含书签数量</small></button>`).join('')}</aside><div><div class="metrics-row"><span><strong>${counts.length+bookmarks}</strong>条笔记</span><span>划线 ${counts.filter(n=>n.type==='highlight').length}</span><span>想法 ${counts.filter(n=>n.type==='thought').length}</span><span>书签 ${bookmarks}</span></div>${tabs([['all','全部内容'],['highlight','划线'],['thought','想法 / 个人点评'],['bookmark','书签数量']],noteType,'note-type')}${noteType==='bookmark'?`<div class="card empty">${bookmarks} 个书签 · 当前接口只提供数量,不提供书签内容。</div>`:list.map(note=>`<article class="note-content"><span class="pill ${note.type==='thought'?'green':''}">${note.type==='thought'?'我的想法':'划线示意'}</span><blockquote>${note.text}</blockquote><small>《${findBook(note.bookId).title}》 · ${note.date}</small><p class="hint">原创示例文本,非书中原文</p><button class="text-button section-space" data-action="personal-note" data-id="${note.id}">查看笔记详情 →</button></article>`).join('')||'<div class="card empty">当前筛选没有笔记。</div>'}<p class="hint">总笔记数包含书签;导出包含划线和个人想法,不含公开点评及书签内容。</p></div></div>`;
}
function exportNotes() {
// 导出按当前选中书目取划线和想法全集,内容类型筛选不影响导出完整性。
const notes=personalNotes.filter(note=>noteBook==='all'||note.bookId===noteBook);
const text='# 阅间笔记 · 演示数据\n\n原创示例,非书中原文。书签仅提供数量,不导出内容。\n\n'+notes.map(n=>`## ${findBook(n.bookId).title}\n\n${n.type==='thought'?'想法':'划线示意'} · ${n.date}\n\n${n.text}`).join('\n\n');
const url=URL.createObjectURL(new Blob([text],{type:'text/markdown;charset=utf-8'}));
showDetail('导出笔记', `<p class="hint">已生成当前书目的划线与想法。可下载 Markdown,或直接选中下方内容复制;不含书签内容。</p><textarea readonly class="export-preview" aria-label="笔记导出内容">${esc(text)}</textarea><div class="goal-actions"><a class="primary" style="text-decoration:none" href="${url}" download="阅间-演示笔记.md">下载 Markdown</a></div>`);
$('#detail').addEventListener('close',()=>URL.revokeObjectURL(url),{once:true});
}
function openBook(id) { detailBook=id;bookTab='info';selectedChapter='all';renderBookDetail(); }
function renderBookDetail() {
const book=findBook(detailBook);
showDetail('书籍详情',`<div class="detail-body">${cover(book)}<div class="detail-info"><div class="eyebrow">READING SPACE / BOOK</div><h3>${book.title}</h3><p>${book.author}</p><p style="color:var(--green)">评分 ${Math.round(Number(book.score)*10)}/100 · 示例评分</p><p>${book.status==='done'?'已读完':`已读 ${book.progress}%`} · 累计 2小时18分钟</p><button class="primary" data-action="add-book" data-id="${book.id}">+ 加入阅读目标</button></div></div><div class="detail-tabs">${[['info','简介'],['chapters','目录'],['notes','我的笔记'],['popular','热门划线'],['reviews','读者点评'],['similar','相似推荐']].map(([id,label])=>`<button data-action="book-tab" data-id="${id}" class="${bookTab===id?'active':''}">${label}</button>`).join('')}</div><div id="book-tab-content">${renderBookTab(book)}</div><p class="hint section-space">全部为设计示例 · 目录、文字和读者资料非真实接口内容。</p>`);
}
function renderBookTab(book) {
if(bookTab==='info')return `<p class="detail-desc">${book.desc}</p><div class="book-details-grid section-space"><div><span>分类:</span>${book.id==='b1'?'科幻':'文学 / 人文'} · 示例</div><div><span>最近阅读:</span>2026-09-11</div><div><span>出版社:</span>示例出版社</div><div><span>译者 / ISBN:</span>暂无演示数据</div><div><span>字数:</span>约 20 万字 · 示例</div><div><span>出版日期:</span>暂无演示数据</div></div>`;
if(bookTab==='chapters')return `<p class="hint">目录结构示意;选择章节可查看该章热门划线与热度。</p>${['序章 · 故事的开始','第一章 · 在旅途中','第二章 · 看见新的世界','第三章 · 留给自己的答案'].map((name,i)=>`<div class="chapter-row"><span>${String(i+1).padStart(2,'0')} &nbsp; ${name}<small style="color:var(--muted)"> · 示例章节</small></span><button class="text-button" data-action="chapter" data-id="${i}">划线热度 →</button></div>`).join('')}`;
if(bookTab==='notes') {
const notes=personalNotes.filter(n=>n.bookId===book.id);
return notes.map(n=>`<div class="note-content"><span class="pill">${n.type==='thought'?'个人想法':'划线示意'}</span><blockquote>${n.text}</blockquote><small>${n.date} · 原创示例文本</small></div>`).join('')||'<div class="detail-empty">这本书暂无个人笔记示例。</div>';
}
if(bookTab==='popular')return `<div class="toolbar"><span class="hint">${selectedChapter==='all'?'全书':`示例章节 ${Number(selectedChapter)+1}`} · 热门划线最多展示 20 条</span><select id="chapter-filter" aria-label="选择热门划线章节"><option value="all">全部章节</option>${[0,1,2,3].map(i=>`<option value="${i}" ${String(i)===selectedChapter?'selected':''}>示例章节 ${i+1}</option>`).join('')}</select></div>${['阅读让我们有机会,在自己的生活之外再生活一次。','那些认真思考过的问题,会在未来的某一天回应你。'].map((text,i)=>`<article class="note-content"><span class="pill">${[1286,862][i]} 人划线 · 示例热度</span><blockquote>${text}</blockquote><small>原创示例文本,非书中原文</small><div class="section-space"><button class="text-button" data-action="highlight-thoughts" data-id="${i}">查看 ${[12,8][i]} 条想法 →</button></div></article>`).join('')}<p class="hint">章节热度提供人数与位置,原文来自热门划线;这里用示例文本说明展示方式。</p>`;
if(bookTab==='reviews')return tabs([['全部','全部'],['推荐','推荐'],['一般','一般'],['不行','不行'],['最新','最新']],reviewFilter,'review-filter')+publicReviews.filter(r=>reviewFilter==='全部'||reviewFilter==='最新'||r.type===reviewFilter).map(review=>`<article class="review-row"><div class="meta"><b>${review.name} · 示例读者</b><span>${'★'.repeat(review.rating)}${'☆'.repeat(5-review.rating)}</span></div><p>${review.text}</p><div class="section-head section-space"><small class="hint">${review.date} · 公开点评示例</small><button class="text-button" data-action="review-detail" data-id="${review.id}">查看详情 →</button></div></article>`).join('');
return `<p class="hint">相似推荐示例 · 根据主题与阅读兴趣推荐</p><div class="books section-space">${books.filter(b=>b.id!==book.id).slice(0,4).map(bookCard).join('')}</div>`;
}
function openThought(text, subtitle) { showDetail('想法详情',`<article class="note-content"><span class="pill">示例内容</span><blockquote>${esc(text)}</blockquote><small>${esc(subtitle)} · 2026-09-11</small></article><p class="hint">这里展示单条想法的完整内容;不生成未经接口提供的评论或点赞列表。</p>`); }
function handleAction(button) {
const action=button.dataset.action,id=button.dataset.id;
switch(action) {
case 'page':navigate(id);break;
case 'new-goal':openGoalEditor();break;
case 'edit-goal':openGoalEditor(id);break;
case 'goal-detail':openGoalDetail(id);break;
case 'goal-filter':goalFilter=id;renderPage();break;
case 'close-detail':$('#detail').close();break;
case 'close-editor':$('#goal-editor').close();break;
case 'pick-list':{
const list=booklists.find(l=>l.id===id);list.ids.forEach(bookId=>draftIds.add(bookId));
if(!draftSources.includes(list.name))draftSources.push(list.name);renderPicker();break;
}
case 'book':openBook(id);break;
case 'list':openList(id);break;
case 'add-book':chooseTarget([id]);break;
case 'add-list':{const list=booklists.find(l=>l.id===id);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;renderPage();break;
case 'period':period=id;renderPage();break;
case 'discover-tab':discoverTab=id;renderPage();break;
case 'retry':dataState='ready';renderPage();break;
case 'notebook':noteBook=id;renderPage();break;
case 'note-type':noteType=id;renderPage();break;
case 'export-notes':exportNotes();break;
case 'personal-note':{const note=personalNotes.find(n=>n.id===id);openThought(note.text,findBook(note.bookId).title+' · 个人笔记示例');break;}
case 'book-tab':bookTab=id;renderBookDetail();break;
case 'chapter':selectedChapter=id;bookTab='popular';renderBookDetail();break;
case 'review-filter':reviewFilter=id;renderBookDetail();break;
case 'review-detail':{const r=publicReviews.find(r=>r.id===id);openThought(r.text,r.name+' · 公开点评示例');break;}
case 'highlight-thoughts':showDetail('划线下的想法',`<div class="note-original">${id==='0'?'阅读让我们有机会,在自己的生活之外再生活一次。':'那些认真思考过的问题,会在未来的某一天回应你。'}<p class="hint">原创划线示意</p></div><article class="review-row"><div class="meta"><b>山间来信 · 示例读者</b><span>2026-09-11</span></div><p>这句话让我想起,每一段阅读经历都有自己的时间。</p><button class="text-button section-space" data-action="thought-detail">查看完整想法 →</button></article><p class="hint">仅展示一条想法示例,真实内容需分页获取。</p>`);break;
case 'thought-detail':openThought('这句话让我想起,每一段阅读经历都有自己的时间。','山间来信 · 公开想法示例');break;
case 'author-books':searchQuery=button.dataset.name;discoverTab='books';$('#global-search').value=searchQuery;renderPage();break;
case 'article-search':searchQuery='';discoverTab='article';navigate('discover');break;
case 'search-example':openThought('这是此类搜索结果的详情布局示意。实际内容、可跳转地址及字段以未来接口返回为准。','搜索结果 · '+id);break;
case 'year-report':showDetail('年度回顾',`<div class="discovery-hero"><div class="eyebrow">2026 / A YEAR IN BOOKS</div><h2>翻过的每一页,都算数。</h2><p>128小时42分钟 · 132 个阅读日 · 示例年度数据</p></div><p class="detail-desc">偏爱文学,习惯在夜晚阅读。每一次阅读,都是给自己的时间。</p><p class="hint section-space">年报布局示意,真实年度报告入口只使用服务端返回的链接。</p>`);break;
}
}
document.addEventListener('click',event=>{
const action=event.target.closest('[data-action]');if(action){handleAction(action);return;}
const nav=event.target.closest('[data-page]');if(nav){searchQuery='';$('#global-search').value='';navigate(nav.dataset.page);}
});
document.addEventListener('change',event=>{
if(event.target.matches('[data-pick-book]')){const id=event.target.dataset.pickBook;event.target.checked?draftIds.add(id):draftIds.delete(id);renderPicker();}
if(event.target.id==='data-state'){dataState=event.target.value;renderPage();}
if(event.target.id==='chapter-filter'){selectedChapter=event.target.value;renderBookDetail();}
});
$('#goal-start').onchange=()=>{$('#goal-end').min=$('#goal-start').value;};
$('#goal-name').oninput=()=>$('#goal-name').setCustomValidity('');
$('#goal-form').onsubmit=saveGoal;$('#picker-search').oninput=renderPicker;
$('#global-search').oninput=event=>{searchQuery=event.target.value.trim();dataState='ready';if(page!=='discover')discoverTab='books';navigate('discover');};
document.addEventListener('input',event=>{
if(event.target.id==='discover-search'){
const position=event.target.selectionStart;searchQuery=event.target.value;$('#global-search').value=searchQuery;renderPage();
$('#discover-search').focus();$('#discover-search').setSelectionRange(position,position);
}
});
document.addEventListener('keydown',event=>{if((event.metaKey||event.ctrlKey)&&event.key==='k'){event.preventDefault();$('#global-search').focus();}});
// 原生 dialog 提供焦点管理与 Escape 关闭;只有点击实际遮罩时才关闭。
document.querySelectorAll('dialog').forEach(dialog=>dialog.addEventListener('click',event=>{
const rect=dialog.getBoundingClientRect();if(event.target===dialog&&(event.clientX<rect.left||event.clientX>rect.right||event.clientY<rect.top||event.clientY>rect.bottom))dialog.close();
}));
navigate('overview');if(storageWarning)toast(storageWarning);
+25
View File
@@ -0,0 +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:'从具体的经济生活出发,理解政府与经济发展的关系。'}];
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']}
];
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:[]}
];
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'}
];
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'}
];
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>阅间 · 微信读书看板</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<svg style="display:none" aria-hidden="true"><defs>
<symbol id="book" viewBox="0 0 24 24"><path d="M3 4c4-1 6 0 9 2 3-2 5-3 9-2v15c-4-1-6 0-9 2-3-2-5-3-9-2zM12 6v15"/></symbol><symbol id="grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></symbol><symbol id="note" viewBox="0 0 24 24"><path d="M13 4H5v16h14v-8M11 13l1-4 7-7 3 3-7 7z"/></symbol><symbol id="search-icon" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></symbol><symbol id="chart" viewBox="0 0 24 24"><path d="M4 3v17h17M8 15v-4m5 4V7m5 8V4"/></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 6v6l4 2"/></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20 14A9 9 0 0 1 10 3a9 9 0 1 0 10 11Z"/></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol><symbol id="sync" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5M6 6a8 8 0 0 1 14 6M4 12a8 8 0 0 0 14 6"/></symbol><symbol id="flag" viewBox="0 0 24 24"><path d="M5 21V3c5-4 9 4 14 0v11c-5 4-9-4-14 0"/></symbol>
</defs></svg>
<aside class="sidebar"><div class="logo"><div class="logo-mark"><svg><use href="#book"/></svg></div><div>阅间<small>READING SPACE</small></div></div><div class="navlabel">我的阅读空间</div><nav class="nav" aria-label="主导航"><button class="active" data-page="overview" title="阅读概览"><svg><use href="#grid"/></svg><span>阅读概览</span></button><button data-page="shelf" title="我的书架"><svg><use href="#book"/></svg><span>我的书架</span><em>8</em></button><button data-page="goals" title="阅读目标"><svg><use href="#flag"/></svg><span>阅读目标</span></button><button data-page="notes" title="笔记与划线"><svg><use href="#note"/></svg><span>笔记与划线</span></button><button data-page="discover" title="发现好书"><svg><use href="#search-icon"/></svg><span>发现好书</span></button></nav><div class="sidebar-bottom"><div class="connection"><span class="dot"></span>微信读书 · 演示空间<small>让每一页,都留下痕迹</small></div><div class="profile"><div class="avatar">L</div><div>林间的读者<small>今天也读一点书</small></div></div></div></aside>
<main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ &nbsp; <b id="crumb" style="font-weight:400">阅读概览</b></span></div><div class="top-actions"><label class="searchbox"><svg><use href="#search-icon"/></svg><input id="global-search" placeholder="搜索书籍、书单…" aria-label="全局搜索"><span class="key">⌘ K</span></label><button class="iconbtn" id="theme" aria-label="切换至暗色主题"><svg><use href="#moon"/></svg></button><div class="avatar">L</div></div></header><div class="demo-banner"><span><span class="dot"></span>设计预览 · 全部为示例数据</span><span>目标保存在当前浏览器</span></div><div id="page-content"></div><footer class="footer"><span>阅间 · 与好书相伴,与自己相遇</span><span>独立设计 Demo · 未连接微信读书账号</span></footer></main>
<dialog id="detail" aria-label="内容详情"><div class="dialog-top"><h2 id="detail-title">书籍详情</h2><button class="iconbtn" data-action="close-detail" aria-label="关闭详情">✕</button></div><div id="detail-content"></div></dialog>
<dialog id="goal-editor" aria-label="编辑阅读目标"><form id="goal-form"><div class="dialog-top"><div><div class="eyebrow">MAKE ROOM FOR READING</div><h2 id="editor-title">新建阅读目标</h2></div><button type="button" class="iconbtn" data-action="close-editor" aria-label="关闭目标编辑">✕</button></div><label class="field">目标名称<input id="goal-name" maxlength="60" placeholder="例如:秋季文学计划" required></label><div class="date-fields"><label class="field">开始日期<input id="goal-start" type="date" required></label><label class="field">结束日期<input id="goal-end" type="date" required></label></div><div class="section-head picker-head"><h3>想读些什么?</h3><span id="selection-count" class="pill"></span></div><p class="hint">选择书籍或加入书单当前书目,重复书籍只算一本,已读完直接计入完成。</p><label class="field"><input id="picker-search" placeholder="筛选书名、作者或书单…" aria-label="筛选目标书目"></label><div id="goal-picker"></div><p id="goal-error" class="form-error" role="alert"></p><div class="editor-footer"><span>仅保存在此浏览器</span><button class="primary" type="submit">保存阅读目标</button></div></form></dialog>
<div id="toast" class="hidden" role="status"></div><script src="demo-data.js"></script><script src="app.js"></script></body></html>
+11
View File
@@ -0,0 +1,11 @@
: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);coloLine truncated
/* 阅读目标与内容页:与既有主题变量共用色彩。 */
: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:10pLine truncated
@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}
Executable
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# weread - 微信读书数据看板 服务管理命令
# 用法:
# weread start [port] 启动服务(默认端口 8080)
# weread stop [port] 停止服务(默认端口 8080)
PROJECT_DIR="/Users/hwj/Codes/MyProjects/WeReadBoard"
PID_FILE="$PROJECT_DIR/.server.pid"
LOG_FILE="/tmp/weread_server.log"
DEFAULT_PORT=8080
get_port() {
echo "${1:-$DEFAULT_PORT}"
}
get_pid() {
local port="$1"
if [ -f "$PID_FILE" ]; then
local saved_port
saved_port=$(sed -n '2p' "$PID_FILE" 2>/dev/null)
local pid
pid=$(head -1 "$PID_FILE" 2>/dev/null)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "$pid"
return
fi
fi
lsof -i :"$port" -t 2>/dev/null | head -1
}
is_running() {
local port="$1"
if [ -n "$(get_pid "$port")" ]; then
return 0
fi
return 1
}
cmd_start() {
local port
port=$(get_port "$1")
if is_running "$port"; then
echo "❌ 服务已在运行 (PID: $(get_pid "$port"), 端口: $port)"
exit 1
fi
echo "启动服务(端口: $port)..."
WEREAD_PORT="$port" nohup python3 "$PROJECT_DIR/app.py" > "$LOG_FILE" 2>&1 &
local pid=$!
printf '%s\n%s\n' "$pid" "$port" > "$PID_FILE"
sleep 2
if kill -0 "$pid" 2>/dev/null; then
echo "✅ 服务已启动"
echo " PID: $pid"
echo " 端口: $port"
echo " 访问: http://127.0.0.1:$port"
echo " 日志: $LOG_FILE"
else
echo "❌ 启动失败,请查看日志: $LOG_FILE"
cat "$LOG_FILE" 2>/dev/null
rm -f "$PID_FILE"
exit 1
fi
}
cmd_stop() {
local port
port=$(get_port "$1")
if ! is_running "$port"; then
echo "服务未运行(端口: $port)"
exit 0
fi
local pid
pid=$(get_pid "$port")
echo "停止服务 (PID: $pid, 端口: $port)..."
kill "$pid" 2>/dev/null
sleep 1
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null
fi
rm -f "$PID_FILE"
echo "✅ 已停止"
}
usage() {
echo "用法:"
echo " weread start [port] 启动服务(默认端口 8080)"
echo " weread stop [port] 停止服务(默认端口 8080)"
exit 1
}
case "${1}" in
start) cmd_start "$2" ;;
stop) cmd_stop "$2" ;;
*) usage ;;
esac