chore(release): 合并 dev 至 1.1
No files matched your search
@@ -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"
|
||||
@@ -29,3 +29,8 @@ Thumbs.db
|
||||
# Env
|
||||
.env
|
||||
.server.pid
|
||||
|
||||
# WorkBuddy workspace memory
|
||||
# Tool artifacts
|
||||
.qoder/
|
||||
.workbuddy/
|
||||
@@ -1,20 +1,29 @@
|
||||
# 微信读书数据展示网站
|
||||
|
||||
基于微信读书 API 的个人读书数据展示网站,数据存储在本地 SQLite 数据库中。
|
||||
基于微信读书 API 的个人读书数据展示网站。
|
||||
|
||||
- **v2「阅间」看板(当前默认前端)**:沿用设计稿的视觉与交互,数据来自 `/api/v2/*` 实时聚合接口,见 `docs/V2_API.md`。
|
||||
- **v1 仪表盘(已归档)**:原 ECharts 单页仪表盘,完整备份在 `legacy/v1/`。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── 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 后生成)
|
||||
├── requirements.txt # Python 依赖
|
||||
├── templates/
|
||||
│ └── index.html # 前端页面
|
||||
│ └── index.html # 「阅间」前端页面(v2 设计稿)
|
||||
└── static/
|
||||
├── css/style.css # 样式文件
|
||||
└── js/app.js # 前端逻辑
|
||||
├── css/styles.css # v2 样式(设计稿原样)
|
||||
├── css/style.css # v1 样式(保留)
|
||||
└── js/
|
||||
├── weapi.js # v2 数据层(封装 /api/v2/*)
|
||||
└── app.js # v2 交互逻辑
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
@@ -42,10 +51,25 @@ python fetch_data.py
|
||||
### 4. 启动网站
|
||||
|
||||
```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` 分支。
|
||||
|
||||
## 功能模块
|
||||
|
||||
|
||||
@@ -18,38 +18,107 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
|
||||
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 应用实例
|
||||
# ======================================================================
|
||||
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||
|
||||
# 注册 v2 数据接口(/api/v2/*,为「阅间」新版前端提供聚合数据)
|
||||
app.register_blueprint(v2_bp)
|
||||
|
||||
# ======================================================================
|
||||
# 数据库连接配置
|
||||
# ======================================================================
|
||||
MYSQL_CONFIG = {
|
||||
"host": "127.0.0.1", # MySQL 服务器地址
|
||||
"port": 3306, # MySQL 端口
|
||||
"user": "root", # 数据库用户名
|
||||
"password": "258963", # 数据库密码
|
||||
"database": "weread_dashboard", # 数据库名称
|
||||
# 默认使用远程 MySQL;仍可通过 WEREAD_DB_* 环境变量覆盖
|
||||
"host": os.environ.get("WEREAD_DB_HOST", "124.222.38.85"),
|
||||
"port": int(os.environ.get("WEREAD_DB_PORT", 13306)),
|
||||
"user": os.environ.get("WEREAD_DB_USER", "root"),
|
||||
"password": os.environ.get("WEREAD_DB_PASSWORD", "258963"),
|
||||
"database": os.environ.get("WEREAD_DB_NAME", "weread_dashboard"),
|
||||
"charset": "utf8mb4", # 字符集(支持完整 Unicode 含 emoji)
|
||||
"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 配置
|
||||
# ======================================================================
|
||||
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"
|
||||
|
||||
|
||||
@@ -147,18 +216,22 @@ def weread_api_call(api_name, **kwargs):
|
||||
Raises:
|
||||
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.update(kwargs)
|
||||
req = urllib.request.Request(
|
||||
WEREAD_API_URL,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {WEREAD_API_KEY}",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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"))
|
||||
|
||||
|
||||
@@ -1192,14 +1265,14 @@ def api_search(conn):
|
||||
def sync_data():
|
||||
"""运行 fetch_data.py + migrate_to_mysql.py,全量同步数据"""
|
||||
project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
python_bin = os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
".workbuddy/binaries/python/envs/default/bin/python",
|
||||
)
|
||||
python_bin = sys.executable or "python3"
|
||||
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"]:
|
||||
return jsonify({"ok": False, "error": "WEREAD_API_KEY 未设置"}), 500
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": "WEREAD_API_KEY 未设置,请检查当前终端环境、项目 .env 或 ~/.zshrc",
|
||||
}), 500
|
||||
|
||||
results = {"steps": []}
|
||||
try:
|
||||
@@ -1270,5 +1343,7 @@ if __name__ == "__main__":
|
||||
else:
|
||||
print(f"[信息] {msg}")
|
||||
|
||||
# 启动 Flask 服务,监听 8080 端口
|
||||
app.run(host="0.0.0.0", port=8080, debug=False)
|
||||
# 监听端口:默认 8080,可用环境变量 PORT 覆盖(v2 前端与 v1 并存时可换端口)
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/` 继续保留给工具使用即可,但不再视为仓库的正式文档来源。
|
||||
@@ -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`)。
|
||||
@@ -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}`。任何写入失败均回滚整批;成功后保留原浏览器备份并记录导入标记。
|
||||
@@ -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`。
|
||||
@@ -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 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。
|
||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
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 语法并通过浏览器完成新建、刷新、编辑和清理闭环。
|
||||
|
||||
不创建分支、提交或合并。本次工作在已检查的非主分支工作区进行,保留既有未提交改动。
|
||||
@@ -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 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。
|
||||
@@ -26,9 +26,10 @@ import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from config_utils import get_env_value
|
||||
|
||||
# ===== 配置 =====
|
||||
API_URL = "https://i.weread.qq.com/api/agent/gateway"
|
||||
API_KEY = os.environ.get("WEREAD_API_KEY", "")
|
||||
SKILL_VERSION = "1.0.4"
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weread.db")
|
||||
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -54,13 +55,17 @@ def now_str():
|
||||
|
||||
def api_call(api_name, **kwargs):
|
||||
"""调用微信读书 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.update(kwargs)
|
||||
req = urllib.request.Request(
|
||||
API_URL,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
@@ -1020,9 +1025,9 @@ def fetch_notebooks(conn):
|
||||
# ===== 主函数 =====
|
||||
|
||||
def main():
|
||||
if not API_KEY:
|
||||
if not get_env_value("WEREAD_API_KEY", ""):
|
||||
print("错误: 请先设置 WEREAD_API_KEY 环境变量")
|
||||
print(" export WEREAD_API_KEY=wrk-xxxxxxxx")
|
||||
print(" 可配置在当前终端、项目 .env,或 ~/.zshrc")
|
||||
return
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
@@ -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')">×</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>
|
||||
@@ -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())
|
||||
@@ -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 +1,2 @@
|
||||
flask>=3.0
|
||||
PyMySQL>=1.1
|
||||
@@ -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
|
||||
@@ -7,11 +7,17 @@
|
||||
# ./start_server.sh restart 重启服务
|
||||
# ./start_server.sh status 查看状态
|
||||
|
||||
PROJECT_DIR="/Users/hwj/WorkBuddy/2026-07-17-09-48-20"
|
||||
PYTHON="/Users/hwj/.workbuddy/binaries/python/envs/default/bin/python"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
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"
|
||||
LOG_FILE="/tmp/weread_server.log"
|
||||
PORT=8080
|
||||
# 默认从 8080 启动;也可通过 PORT 或 WEREAD_PORT 指定端口
|
||||
PORT="${PORT:-${WEREAD_PORT:-8080}}"
|
||||
|
||||
cd "$PROJECT_DIR" || exit 1
|
||||
|
||||
@@ -22,10 +28,6 @@ is_running() {
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# 也检查端口是否被占用
|
||||
if lsof -i :$PORT -t >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -37,7 +39,27 @@ get_pid() {
|
||||
return
|
||||
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() {
|
||||
@@ -58,27 +80,43 @@ stop_server() {
|
||||
|
||||
start_foreground() {
|
||||
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
|
||||
fi
|
||||
echo "启动服务(前台模式)..."
|
||||
exec "$PYTHON" app.py
|
||||
if [ -z "$PYTHON" ]; then
|
||||
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() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
find_free_port
|
||||
fi
|
||||
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
|
||||
fi
|
||||
echo "启动服务(后台守护模式)..."
|
||||
WEREAD_API_KEY="${WEREAD_API_KEY:-wrk-ZL_oy0woT5yKFaO1jn_15AAA}" \
|
||||
nohup "$PYTHON" app.py > "$LOG_FILE" 2>&1 &
|
||||
if [ -z "$PYTHON" ]; then
|
||||
echo "未找到可用的 Python 解释器"
|
||||
exit 1
|
||||
fi
|
||||
echo "启动服务(后台守护模式,端口: $PORT)..."
|
||||
PORT="$PORT" WEREAD_PORT="$PORT" nohup "$PYTHON" app.py > "$LOG_FILE" 2>&1 &
|
||||
local pid=$!
|
||||
echo "$pid" > "$PID_FILE"
|
||||
sleep 2
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "服务已启动 (PID: $pid)"
|
||||
echo "访问: http://127.0.0.1:$PORT"
|
||||
echo "端口: $PORT"
|
||||
echo "访问地址: http://127.0.0.1:$PORT"
|
||||
echo "日志: $LOG_FILE"
|
||||
else
|
||||
echo "启动失败,请查看日志: $LOG_FILE"
|
||||
|
||||
@@ -748,6 +748,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shelf-search {
|
||||
@@ -769,6 +770,7 @@ body {
|
||||
.shelf-refresh-btn {
|
||||
padding: 0 16px;
|
||||
height: 36px;
|
||||
min-width: 112px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
@@ -781,7 +783,7 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 112px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -789,6 +791,15 @@ body {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
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) {
|
||||
@@ -801,7 +812,7 @@ body {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.shelf-refresh-btn.loading {
|
||||
.shelf-refresh-btn.is-loading {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
@@ -2022,6 +2033,9 @@ body {
|
||||
.shelf-search {
|
||||
width: 140px;
|
||||
}
|
||||
.shelf-filters {
|
||||
gap: 10px;
|
||||
}
|
||||
.shelf-categories {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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'}
|
||||
];
|
||||
@@ -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);
|
||||
@@ -1,211 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<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>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>阅间 · 微信读书看板</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css">
|
||||
</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')">×</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>
|
||||
<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">读</div><div>我的阅读空间<small>数据来自你的微信读书账号</small></div></div></div></aside>
|
||||
<main class="main"><header class="topbar"><div class="breadcrumb">我的空间 <span>/ <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>
|
||||
<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>
|
||||
@@ -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, /正在读取阅读目标/);
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,273 @@
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const esc = value => String(value).replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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')} ${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);
|
||||
@@ -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'}
|
||||
];
|
||||
@@ -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>/ <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>
|
||||
@@ -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}
|
||||
@@ -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
|
||||