Initial commit
This commit is contained in:
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user