Initial commit

This commit is contained in:
kai
2026-03-25 13:20:39 +08:00
commit c1ecce9719
8 changed files with 992 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# 📚 Ecliptic Pathfinder — 个人图书阅读管理
轻量级命令行图书管理工具,零依赖,纯 Python 标准库。
## 快速开始
```bash
# 添加书目
python bookshelf.py add "深度工作" --author "卡尔·纽波特" --format paper --status reading --tags "效率,自我管理"
# 列出所有书
python bookshelf.py list
# 按状态/格式/标签筛选
python bookshelf.py list --status reading
python bookshelf.py list --format ebook
python bookshelf.py list --tag "哲学"
# 更新书目
python bookshelf.py update 1 --status read --rating 5 --finish-date 2026-03-25
# 删除书目
python bookshelf.py delete 1
# 生成展示网页
python bookshelf.py build
# 然后用浏览器打开 output/index.html
```
## 完整字段
| 参数 | 说明 | 示例 |
|------|------|------|
| `title` (必填) | 书名 | `"深度工作"` |
| `--author` | 作者 | `"卡尔·纽波特"` |
| `--translator` | 译者 | `"宋伟"` |
| `--publisher` | 出版社 | `"后浪出版"` |
| `--pub-date` | 出版日期 | `"2017-09"` |
| `--cover-url` | 封面 URL | `"https://..."` |
| `--format` | `paper` / `ebook` | `paper` |
| `--status` | `to-read` / `reading` / `read` | `reading` |
| `--rating` | 个人评分 1-5 | `5` |
| `--douban-score` | 豆瓣评分 | `7.9` |
| `--goodreads-score` | GR 评分 | `4.18` |
| `--tags` | 逗号分隔标签 | `"哲学,认知科学"` |
| `--notes` | 简评 | `"值得反复阅读"` |
| `--start-date` | 开始日期 | `"2026-03-01"` |
| `--finish-date` | 完成日期 | `"2026-03-20"` |
## 数据存储
- 数据库:`books.db`SQLite,自动创建)
- 网页输出:`output/index.html`(静态 HTML,浏览器直接打开)
Binary file not shown.
Binary file not shown.
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""bookshelf — 图书阅读管理 CLI。"""
import argparse
import sys
import db
import web
STATUS_CHOICES = ["to-read", "reading", "read"]
FORMAT_CHOICES = ["paper", "ebook"]
def cmd_add(args):
db.init_db()
book_id = db.add_book(
args.title,
author=args.author,
translator=args.translator,
publisher=args.publisher,
pub_date=args.pub_date,
cover_url=args.cover_url,
format=args.format,
status=args.status,
rating=args.rating,
douban_score=args.douban_score,
goodreads_score=args.goodreads_score,
tags=args.tags or "",
notes=args.notes,
start_date=args.start_date,
finish_date=args.finish_date,
)
print(f"✅ 已添加:《{args.title}》(ID: {book_id})")
def cmd_list(args):
db.init_db()
books = db.list_books(status=args.status, format=args.format, tag=args.tag)
if not books:
print("📚 暂无书目。")
return
for b in books:
status_icon = {"to-read": "📖", "reading": "📗", "read": ""}.get(b["status"], "📕")
fmt_icon = "📱" if b["format"] == "ebook" else "📕"
rating_str = f"{b['rating']}" if b["rating"] else ""
tags_str = f" [{b['tags']}]" if b["tags"] else ""
print(f" {status_icon} [{b['id']:>3}] 《{b['title']}》— {b['author'] or '未知'}"
f" {fmt_icon}{rating_str}{tags_str}")
def cmd_update(args):
db.init_db()
book = db.get_book(args.id)
if not book:
print(f"❌ 未找到 ID 为 {args.id} 的书目。")
sys.exit(1)
updates = {}
for field in ["title", "author", "translator", "publisher", "pub_date",
"cover_url", "format", "status", "rating", "douban_score",
"goodreads_score", "tags", "notes", "start_date", "finish_date"]:
val = getattr(args, field.replace("-", "_"), None)
if val is not None:
updates[field] = val
if not updates:
print("⚠️ 未指定任何要更新的字段。")
return
db.update_book(args.id, **updates)
print(f"✅ 已更新:《{book['title']}》(ID: {args.id})")
def cmd_delete(args):
db.init_db()
book = db.get_book(args.id)
if not book:
print(f"❌ 未找到 ID 为 {args.id} 的书目。")
sys.exit(1)
db.delete_book(args.id)
print(f"🗑️ 已删除:《{book['title']}》(ID: {args.id})")
def cmd_build(args):
db.init_db()
output_path = web.build()
print(f"🌐 网页已生成:{output_path}")
def main():
parser = argparse.ArgumentParser(
prog="bookshelf",
description="📚 个人图书阅读管理工具",
)
sub = parser.add_subparsers(dest="command", required=True)
# --- add ---
p_add = sub.add_parser("add", help="添加书目")
p_add.add_argument("title", help="书名")
p_add.add_argument("--author", help="作者")
p_add.add_argument("--translator", help="译者")
p_add.add_argument("--publisher", help="出版社")
p_add.add_argument("--pub-date", help="出版日期")
p_add.add_argument("--cover-url", help="封面图片 URL")
p_add.add_argument("--format", choices=FORMAT_CHOICES, default="paper", help="格式")
p_add.add_argument("--status", choices=STATUS_CHOICES, default="to-read", help="阅读状态")
p_add.add_argument("--rating", type=int, choices=range(1, 6), help="个人评分 1-5")
p_add.add_argument("--douban-score", type=float, help="豆瓣评分")
p_add.add_argument("--goodreads-score", type=float, help="Goodreads 评分")
p_add.add_argument("--tags", help="阅读主题标签,逗号分隔")
p_add.add_argument("--notes", help="笔记/简评")
p_add.add_argument("--start-date", help="开始阅读日期")
p_add.add_argument("--finish-date", help="完成阅读日期")
p_add.set_defaults(func=cmd_add)
# --- list ---
p_list = sub.add_parser("list", help="列出书目")
p_list.add_argument("--status", choices=STATUS_CHOICES, help="按状态筛选")
p_list.add_argument("--format", choices=FORMAT_CHOICES, help="按格式筛选")
p_list.add_argument("--tag", help="按标签筛选")
p_list.set_defaults(func=cmd_list)
# --- update ---
p_upd = sub.add_parser("update", help="更新书目")
p_upd.add_argument("id", type=int, help="书目 ID")
p_upd.add_argument("--title", help="书名")
p_upd.add_argument("--author", help="作者")
p_upd.add_argument("--translator", help="译者")
p_upd.add_argument("--publisher", help="出版社")
p_upd.add_argument("--pub-date", help="出版日期")
p_upd.add_argument("--cover-url", help="封面图片 URL")
p_upd.add_argument("--format", choices=FORMAT_CHOICES, help="格式")
p_upd.add_argument("--status", choices=STATUS_CHOICES, help="阅读状态")
p_upd.add_argument("--rating", type=int, choices=range(1, 6), help="个人评分 1-5")
p_upd.add_argument("--douban-score", type=float, help="豆瓣评分")
p_upd.add_argument("--goodreads-score", type=float, help="Goodreads 评分")
p_upd.add_argument("--tags", help="阅读主题标签,逗号分隔")
p_upd.add_argument("--notes", help="笔记/简评")
p_upd.add_argument("--start-date", help="开始阅读日期")
p_upd.add_argument("--finish-date", help="完成阅读日期")
p_upd.set_defaults(func=cmd_update)
# --- delete ---
p_del = sub.add_parser("delete", help="删除书目")
p_del.add_argument("id", type=int, help="书目 ID")
p_del.set_defaults(func=cmd_delete)
# --- build ---
p_build = sub.add_parser("build", help="生成展示网页")
p_build.set_defaults(func=cmd_build)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
+153
View File
@@ -0,0 +1,153 @@
"""数据库操作层 — 封装所有 SQLite 操作。"""
import sqlite3
import os
from datetime import datetime
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "books.db")
def _connect():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
"""初始化数据库和表结构。"""
conn = _connect()
conn.execute("""
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT,
translator TEXT,
publisher TEXT,
pub_date TEXT,
cover_url TEXT,
format TEXT DEFAULT 'paper',
status TEXT DEFAULT 'to-read',
rating INTEGER,
douban_score REAL,
goodreads_score REAL,
tags TEXT DEFAULT '',
notes TEXT,
start_date TEXT,
finish_date TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
""")
conn.commit()
conn.close()
def add_book(title, *, author=None, translator=None, publisher=None,
pub_date=None, cover_url=None, format="paper", status="to-read",
rating=None, douban_score=None, goodreads_score=None,
tags="", notes=None, start_date=None, finish_date=None):
"""添加一本书,返回新书 ID。"""
conn = _connect()
cur = conn.execute("""
INSERT INTO books (title, author, translator, publisher, pub_date,
cover_url, format, status, rating, douban_score,
goodreads_score, tags, notes, start_date, finish_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (title, author, translator, publisher, pub_date, cover_url,
format, status, rating, douban_score, goodreads_score,
tags, notes, start_date, finish_date))
conn.commit()
book_id = cur.lastrowid
conn.close()
return book_id
def update_book(book_id, **kwargs):
"""更新书目字段。只更新传入的字段。"""
if not kwargs:
return
allowed = {
"title", "author", "translator", "publisher", "pub_date",
"cover_url", "format", "status", "rating", "douban_score",
"goodreads_score", "tags", "notes", "start_date", "finish_date",
}
fields = {k: v for k, v in kwargs.items() if k in allowed}
if not fields:
return
fields["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
set_clause = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [book_id]
conn = _connect()
conn.execute(f"UPDATE books SET {set_clause} WHERE id = ?", values)
conn.commit()
conn.close()
def delete_book(book_id):
"""删除一本书。"""
conn = _connect()
conn.execute("DELETE FROM books WHERE id = ?", (book_id,))
conn.commit()
conn.close()
def get_book(book_id):
"""获取单本书详情,返回 dict 或 None。"""
conn = _connect()
row = conn.execute("SELECT * FROM books WHERE id = ?", (book_id,)).fetchone()
conn.close()
return dict(row) if row else None
def list_books(*, status=None, format=None, tag=None):
"""查询书目列表,支持按状态/格式/标签过滤。"""
conn = _connect()
query = "SELECT * FROM books WHERE 1=1"
params = []
if status:
query += " AND status = ?"
params.append(status)
if format:
query += " AND format = ?"
params.append(format)
if tag:
# 逗号分隔的 tags 字段中模糊匹配
query += " AND (',' || tags || ',' LIKE ?)"
params.append(f"%,{tag},%")
query += " ORDER BY updated_at DESC"
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_stats():
"""获取统计数据。"""
conn = _connect()
total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0]
by_status = {}
for row in conn.execute("SELECT status, COUNT(*) as cnt FROM books GROUP BY status"):
by_status[row["status"]] = row["cnt"]
by_format = {}
for row in conn.execute("SELECT format, COUNT(*) as cnt FROM books GROUP BY format"):
by_format[row["format"]] = row["cnt"]
conn.close()
return {
"total": total,
"by_status": by_status,
"by_format": by_format,
}
def get_all_tags():
"""获取所有不重复的标签列表。"""
conn = _connect()
rows = conn.execute("SELECT tags FROM books WHERE tags != ''").fetchall()
conn.close()
tag_set = set()
for row in rows:
for t in row["tags"].split(","):
t = t.strip()
if t:
tag_set.add(t)
return sorted(tag_set)
+341
View File
@@ -0,0 +1,341 @@
/* ========================================
我的书架 — 暗色主题样式
======================================== */
:root {
--bg-primary: #0f1117;
--bg-secondary: #1a1d2e;
--bg-card: #222639;
--bg-card-hover: #2a2f48;
--text-primary: #e8eaf0;
--text-secondary: #9ca3b8;
--text-muted: #6b7394;
--accent: #7c6aef;
--accent-glow: rgba(124, 106, 239, 0.3);
--accent-light: #a78bfa;
--reading: #34d399;
--read: #60a5fa;
--to-read: #fbbf24;
--paper: #f97316;
--ebook: #06b6d4;
--border: rgba(255, 255, 255, 0.06);
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
}
/* Hero */
.hero {
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4c1d95 100%);
padding: 3rem 1.5rem 2.5rem;
text-align: center;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 30% 50%, rgba(124, 106, 239, 0.15) 0%, transparent 60%),
radial-gradient(circle at 80% 30%, rgba(167, 139, 250, 0.1) 0%, transparent 50%);
}
.hero-inner {
position: relative;
z-index: 1;
}
.hero h1 {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 0.4rem;
background: linear-gradient(to right, #e0e7ff, #c4b5fd);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-sub {
color: rgba(255,255,255,0.5);
font-size: 1rem;
font-weight: 300;
}
/* Stats Bar */
.stats-bar {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 1.2rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.6rem 1.2rem;
border-radius: var(--radius-sm);
background: var(--bg-card);
min-width: 80px;
transition: transform var(--transition);
}
.stat:hover {
transform: translateY(-2px);
}
.stat-num {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat--reading .stat-num { color: var(--reading); }
.stat--read .stat-num { color: var(--read); }
.stat--to-read .stat-num { color: var(--to-read); }
.stat--paper .stat-num { color: var(--paper); }
.stat--ebook .stat-num { color: var(--ebook); }
/* Navigation */
.view-nav {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
background: var(--bg-primary);
}
.view-btn, .filter-btn {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 0.5rem 1.2rem;
border-radius: 999px;
font-size: 0.9rem;
cursor: pointer;
transition: all var(--transition);
font-family: inherit;
}
.view-btn:hover, .filter-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.view-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 0 16px var(--accent-glow);
}
.filter-btn.active {
background: rgba(124, 106, 239, 0.15);
border-color: var(--accent);
color: var(--accent-light);
}
.filter-nav {
display: flex;
justify-content: center;
gap: 0.4rem;
padding: 0 1rem 1rem;
flex-wrap: wrap;
}
.filter-nav.hidden {
display: none;
}
/* Book Grid */
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.2rem;
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
/* Book Card */
.book-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: all var(--transition);
display: flex;
flex-direction: column;
}
.book-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow);
border-color: rgba(124, 106, 239, 0.2);
}
.card-cover {
width: 100%;
height: 200px;
object-fit: cover;
display: block;
}
.card-cover--placeholder {
height: 160px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card-hover));
}
.card-body {
padding: 1rem 1.2rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.4;
color: var(--text-primary);
}
.card-meta {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.5;
}
.card-rating.personal {
font-size: 0.95rem;
color: var(--to-read);
letter-spacing: 0.08em;
}
.card-scores {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.score {
font-size: 0.78rem;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-weight: 500;
}
.score.douban {
background: rgba(0, 180, 0, 0.1);
color: #4ade80;
border: 1px solid rgba(0, 180, 0, 0.15);
}
.score.goodreads {
background: rgba(96, 165, 250, 0.1);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.15);
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tag {
font-size: 0.72rem;
padding: 0.15rem 0.5rem;
background: rgba(124, 106, 239, 0.1);
color: var(--accent-light);
border-radius: 999px;
border: 1px solid rgba(124, 106, 239, 0.15);
}
.card-notes {
font-size: 0.82rem;
color: var(--text-muted);
font-style: italic;
border-left: 2px solid var(--accent);
padding-left: 0.6rem;
}
.card-dates {
font-size: 0.75rem;
color: var(--text-muted);
}
.card-format {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: auto;
padding-top: 0.4rem;
border-top: 1px solid var(--border);
}
/* Footer */
.footer {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-size: 0.8rem;
border-top: 1px solid var(--border);
margin-top: 2rem;
}
/* Responsive */
@media (max-width: 640px) {
.hero h1 { font-size: 1.8rem; }
.stats-bar { gap: 0.3rem; padding: 0.8rem 0.5rem; }
.stat { min-width: 60px; padding: 0.4rem 0.6rem; }
.stat-num { font-size: 1.2rem; }
.book-grid { grid-template-columns: 1fr; padding: 1rem; }
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.book-card {
animation: fadeIn 0.4s ease both;
}
.book-card:nth-child(2) { animation-delay: 0.05s; }
.book-card:nth-child(3) { animation-delay: 0.1s; }
.book-card:nth-child(4) { animation-delay: 0.15s; }
.book-card:nth-child(5) { animation-delay: 0.2s; }
.book-card:nth-child(6) { animation-delay: 0.25s; }
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的书架 — Ecliptic Pathfinder</title>
<meta name="description" content="个人图书阅读管理 — 阅读进度与书库展示">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>{{CSS}}</style>
</head>
<body>
<header class="hero">
<div class="hero-inner">
<h1>📚 我的书架</h1>
<p class="hero-sub">阅读是最好的投资</p>
</div>
</header>
<!-- 统计面板 -->
<section class="stats-bar">
<div class="stat">
<span class="stat-num">{{TOTAL}}</span>
<span class="stat-label">总计</span>
</div>
<div class="stat stat--reading">
<span class="stat-num">{{READING}}</span>
<span class="stat-label">在读</span>
</div>
<div class="stat stat--read">
<span class="stat-num">{{READ}}</span>
<span class="stat-label">已读</span>
</div>
<div class="stat stat--to-read">
<span class="stat-num">{{TO_READ}}</span>
<span class="stat-label">待读</span>
</div>
<div class="stat stat--paper">
<span class="stat-num">{{PAPER}}</span>
<span class="stat-label">纸质</span>
</div>
<div class="stat stat--ebook">
<span class="stat-num">{{EBOOK}}</span>
<span class="stat-label">电子</span>
</div>
</section>
<!-- 视图切换 -->
<nav class="view-nav">
<button class="view-btn active" data-view="progress">📖 阅读进度</button>
<button class="view-btn" data-view="library">📚 书库</button>
<button class="view-btn" data-view="tags">🏷️ 主题</button>
</nav>
<!-- 进度视图的子筛选 -->
<nav class="filter-nav" id="filter-progress">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="reading">在读</button>
<button class="filter-btn" data-filter="read">已读</button>
<button class="filter-btn" data-filter="to-read">待读</button>
</nav>
<!-- 书库视图的子筛选 -->
<nav class="filter-nav hidden" id="filter-library">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="paper">📕 纸质书</button>
<button class="filter-btn" data-filter="ebook">📱 电子书</button>
</nav>
<!-- 标签视图的按钮 -->
<nav class="filter-nav hidden" id="filter-tags">
<button class="filter-btn tag-btn active" data-tag="">全部</button>
{{TAG_BUTTONS}}
</nav>
<!-- 书目卡片列表 -->
<main class="book-grid">
{{BOOK_CARDS}}
</main>
<footer class="footer">
<p>Ecliptic Pathfinder · 生成于 <span id="gen-time"></span></p>
</footer>
<script>
document.getElementById('gen-time').textContent = new Date().toLocaleDateString('zh-CN');
const cards = document.querySelectorAll('.book-card');
const viewBtns = document.querySelectorAll('.view-btn');
const filterNavs = {
progress: document.getElementById('filter-progress'),
library: document.getElementById('filter-library'),
tags: document.getElementById('filter-tags'),
};
let currentView = 'progress';
let currentFilter = 'all';
let currentTag = '';
function applyFilters() {
cards.forEach(card => {
let show = true;
if (currentView === 'progress' && currentFilter !== 'all') {
show = card.dataset.status === currentFilter;
} else if (currentView === 'library' && currentFilter !== 'all') {
show = card.dataset.format === currentFilter;
} else if (currentView === 'tags' && currentTag) {
const cardTags = card.dataset.tags.split(',').map(t => t.trim());
show = cardTags.includes(currentTag);
}
card.style.display = show ? '' : 'none';
});
}
viewBtns.forEach(btn => {
btn.addEventListener('click', () => {
viewBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentView = btn.dataset.view;
currentFilter = 'all';
currentTag = '';
Object.values(filterNavs).forEach(n => n.classList.add('hidden'));
if (filterNavs[currentView]) filterNavs[currentView].classList.remove('hidden');
// reset sub-filter active state
filterNavs[currentView]?.querySelectorAll('.filter-btn').forEach((b, i) => {
b.classList.toggle('active', i === 0);
});
applyFilters();
});
});
document.querySelectorAll('.filter-nav').forEach(nav => {
nav.addEventListener('click', e => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
nav.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
if (btn.dataset.tag !== undefined) {
currentTag = btn.dataset.tag;
currentFilter = 'all';
} else {
currentFilter = btn.dataset.filter;
}
applyFilters();
});
});
</script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
"""网页生成器 — 读取数据库,生成静态 HTML 展示页面。"""
import os
import json
import db
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATE_PATH = os.path.join(BASE_DIR, "templates", "index.html")
STYLE_PATH = os.path.join(BASE_DIR, "static", "style.css")
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
OUTPUT_PATH = os.path.join(OUTPUT_DIR, "index.html")
def _escape(text):
"""简单的 HTML 转义。"""
if not text:
return ""
return (str(text)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
def _render_book_card(book):
"""渲染单本书的 HTML 卡片。"""
cover = book.get("cover_url") or ""
cover_html = (f'<img class="card-cover" src="{_escape(cover)}" alt="封面" loading="lazy">'
if cover
else '<div class="card-cover card-cover--placeholder">📖</div>')
rating_html = ""
if book.get("rating"):
stars = "" * book["rating"] + "" * (5 - book["rating"])
rating_html = f'<div class="card-rating personal">{stars}</div>'
scores = []
if book.get("douban_score"):
scores.append(f'<span class="score douban">豆瓣 {book["douban_score"]}</span>')
if book.get("goodreads_score"):
scores.append(f'<span class="score goodreads">GR {book["goodreads_score"]}</span>')
scores_html = f'<div class="card-scores">{"".join(scores)}</div>' if scores else ""
tags_html = ""
if book.get("tags"):
tag_spans = "".join(
f'<span class="tag" data-tag="{_escape(t.strip())}">{_escape(t.strip())}</span>'
for t in book["tags"].split(",") if t.strip()
)
tags_html = f'<div class="card-tags">{tag_spans}</div>'
meta_parts = []
if book.get("author"):
meta_parts.append(f'{_escape(book["author"])}')
if book.get("translator"):
meta_parts.append(f'译: {_escape(book["translator"])}')
if book.get("publisher"):
meta_parts.append(f'{_escape(book["publisher"])}')
if book.get("pub_date"):
meta_parts.append(f'{_escape(book["pub_date"])}')
meta_html = f'<div class="card-meta">{" · ".join(meta_parts)}</div>' if meta_parts else ""
notes_html = ""
if book.get("notes"):
notes_html = f'<div class="card-notes">{_escape(book["notes"])}</div>'
dates = []
if book.get("start_date"):
dates.append(f'开始: {_escape(book["start_date"])}')
if book.get("finish_date"):
dates.append(f'完成: {_escape(book["finish_date"])}')
dates_html = f'<div class="card-dates">{" · ".join(dates)}</div>' if dates else ""
format_badge = "📱 电子书" if book.get("format") == "ebook" else "📕 纸质书"
return f"""
<div class="book-card" data-status="{_escape(book.get('status', ''))}"
data-format="{_escape(book.get('format', ''))}"
data-tags="{_escape(book.get('tags', ''))}">
{cover_html}
<div class="card-body">
<h3 class="card-title">{_escape(book.get('title', ''))}</h3>
{meta_html}
{rating_html}
{scores_html}
{tags_html}
{notes_html}
{dates_html}
<div class="card-format">{format_badge}</div>
</div>
</div>"""
def build():
"""生成静态 HTML 页面,返回输出路径。"""
os.makedirs(OUTPUT_DIR, exist_ok=True)
books = db.list_books()
stats = db.get_stats()
all_tags = db.get_all_tags()
cards_html = "\n".join(_render_book_card(b) for b in books)
# 读取 CSS
with open(STYLE_PATH, "r", encoding="utf-8") as f:
css = f.read()
# 读取模板并填充
with open(TEMPLATE_PATH, "r", encoding="utf-8") as f:
template = f.read()
# 统计数据
total = stats["total"]
reading = stats["by_status"].get("reading", 0)
read = stats["by_status"].get("read", 0)
to_read = stats["by_status"].get("to-read", 0)
paper = stats["by_format"].get("paper", 0)
ebook = stats["by_format"].get("ebook", 0)
# 标签按钮
tag_buttons = "".join(
f'<button class="tag-btn" data-tag="{_escape(t)}">{_escape(t)}</button>'
for t in all_tags
)
html = (template
.replace("{{CSS}}", css)
.replace("{{TOTAL}}", str(total))
.replace("{{READING}}", str(reading))
.replace("{{READ}}", str(read))
.replace("{{TO_READ}}", str(to_read))
.replace("{{PAPER}}", str(paper))
.replace("{{EBOOK}}", str(ebook))
.replace("{{TAG_BUTTONS}}", tag_buttons)
.replace("{{BOOK_CARDS}}", cards_html))
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
f.write(html)
# 复制封面图片不处理(直接使用 URL)
return OUTPUT_PATH