diff --git a/app/main.py b/app/main.py
index ca95709..a26fa4b 100644
--- a/app/main.py
+++ b/app/main.py
@@ -11,6 +11,8 @@ from app.database import engine
from app.models import init_db
from app.routes.admin import router as admin_router
from app.routes.pages import router as pages_router
+from app.routes.search import router as search_router
+from app.routes.user import router as user_router
logging.basicConfig(
level=logging.DEBUG if settings.APP_DEBUG else logging.INFO,
@@ -43,6 +45,8 @@ def create_app() -> FastAPI:
# 路由
app.include_router(pages_router)
app.include_router(admin_router)
+ app.include_router(search_router)
+ app.include_router(user_router)
return app
diff --git a/app/routes/search.py b/app/routes/search.py
new file mode 100644
index 0000000..ab9e59d
--- /dev/null
+++ b/app/routes/search.py
@@ -0,0 +1,249 @@
+"""搜索、阅读列表、RSS Feed 路由。"""
+
+from __future__ import annotations
+
+import math
+from datetime import date, datetime, timedelta, timezone
+from zoneinfo import ZoneInfo
+from xml.sax.saxutils import escape
+
+from fastapi import APIRouter, Depends, Query, Request
+from fastapi.responses import Response
+from fastapi.templating import Jinja2Templates
+from sqlalchemy import text
+from sqlalchemy.orm import Session, joinedload
+
+from app.config import settings
+from app.database import get_db
+from app.models import Paper, PaperTag, UserReadingStatus
+from app.services.searcher import get_all_tags, search_papers
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app/templates")
+
+
+# ── 搜索页 ────────────────────────────────────────────────────────────
+
+
+@router.get("/search")
+def search_page(
+ request: Request,
+ q: str = Query(default=""),
+ tag: str = Query(default=""),
+ sort: str = Query(default="relevance"),
+ page: int = Query(default=1, ge=1),
+ db: Session = Depends(get_db),
+):
+ """搜索页面。"""
+ result = search_papers(db, query=q or None, tag=tag or None, sort=sort, page=page)
+ all_tags = get_all_tags(db)
+
+ return templates.TemplateResponse(
+ request,
+ "search.html",
+ {
+ "query": q,
+ "tag": tag,
+ "sort": sort,
+ "results": result["results"],
+ "snippets": result["snippets"],
+ "total": result["total"],
+ "page": result["page"],
+ "total_pages": result["total_pages"],
+ "all_tags": all_tags,
+ "page_title": f"搜索: {q}" if q else "搜索",
+ "today": _today_str(),
+ },
+ )
+
+
+# ── 搜索 JSON API ─────────────────────────────────────────────────────
+
+
+@router.get("/api/search")
+def search_api(
+ q: str = Query(default=""),
+ tag: str = Query(default=""),
+ sort: str = Query(default="relevance"),
+ page: int = Query(default=1, ge=1),
+ db: Session = Depends(get_db),
+):
+ """搜索 JSON API。"""
+ result = search_papers(db, query=q or None, tag=tag or None, sort=sort, page=page)
+
+ items = []
+ for paper in result["results"]:
+ snippet = result["snippets"].get(paper.id, {})
+ items.append(
+ {
+ "arxiv_id": paper.arxiv_id,
+ "title_en": paper.title_en,
+ "title_zh": paper.title_zh,
+ "paper_date": paper.paper_date.isoformat() if paper.paper_date else None,
+ "upvotes": paper.upvotes,
+ "tags": [t.tag for t in paper.tags],
+ "authors": [a.name for a in paper.authors],
+ "snippet_title_zh": snippet.get("title_zh"),
+ "snippet_abstract": snippet.get("abstract"),
+ }
+ )
+
+ return {
+ "results": items,
+ "total": result["total"],
+ "page": result["page"],
+ "total_pages": result["total_pages"],
+ }
+
+
+# ── 阅读列表 ──────────────────────────────────────────────────────────
+
+
+@router.get("/reading-list")
+def reading_list_page(
+ request: Request,
+ filter: str = Query(default="all"),
+ tag: str = Query(default=""),
+ db: Session = Depends(get_db),
+):
+ """阅读列表页面。"""
+ papers = _query_reading_list(db, filter, tag or None)
+ all_tags = get_all_tags(db)
+
+ return templates.TemplateResponse(
+ request,
+ "reading_list.html",
+ {
+ "papers": papers,
+ "current_filter": filter,
+ "current_tag": tag,
+ "all_tags": all_tags,
+ "page_title": "阅读列表",
+ "today": _today_str(),
+ },
+ )
+
+
+def _query_reading_list(
+ db: Session,
+ filter_type: str,
+ tag: str | None,
+) -> list[Paper]:
+ """根据筛选条件查询阅读列表。"""
+ from sqlalchemy import or_
+
+ # 基础:有任意用户数据的论文
+ base = db.query(Paper).filter(
+ or_(
+ Paper.bookmark.has(),
+ Paper.reading_status.has(),
+ Paper.note.has(),
+ )
+ )
+
+ # 应用筛选
+ if filter_type == "has_note":
+ base = base.filter(Paper.note.has())
+ elif filter_type in ("unread", "skimmed", "read_summary", "read_full"):
+ base = base.filter(
+ Paper.reading_status.has(UserReadingStatus.status == filter_type)
+ )
+
+ # 应用标签
+ if tag:
+ base = base.filter(Paper.tags.any(PaperTag.tag == tag))
+
+ return (
+ base.options(
+ joinedload(Paper.authors),
+ joinedload(Paper.tags),
+ joinedload(Paper.summary_status),
+ joinedload(Paper.bookmark),
+ joinedload(Paper.reading_status),
+ joinedload(Paper.note),
+ )
+ .order_by(Paper.paper_date.desc(), Paper.upvotes.desc())
+ .all()
+ )
+
+
+# ── RSS Feed ──────────────────────────────────────────────────────────
+
+
+@router.get("/rss.xml")
+def rss_feed(
+ tag: str = Query(default=""),
+ db: Session = Depends(get_db),
+):
+ """RSS 2.0 Feed — 最近 7 天论文。"""
+ seven_days_ago = date.today() - timedelta(days=7)
+
+ query = (
+ db.query(Paper)
+ .filter(Paper.paper_date >= seven_days_ago)
+ .options(
+ joinedload(Paper.authors),
+ joinedload(Paper.tags),
+ joinedload(Paper.summary),
+ )
+ .order_by(Paper.paper_date.desc(), Paper.upvotes.desc())
+ )
+
+ if tag:
+ query = query.filter(Paper.tags.any(PaperTag.tag == tag))
+
+ papers = query.all()
+ xml = _generate_rss_xml(papers, settings.BASE_URL, tag or None)
+ return Response(content=xml, media_type="application/xml")
+
+
+def _generate_rss_xml(papers: list[Paper], base_url: str, tag: str | None) -> str:
+ """生成 RSS 2.0 XML。"""
+ lines = ['']
+ lines.append('')
+ lines.append(" ")
+
+ channel_title = "HF Daily Papers"
+ if tag:
+ channel_title += f" — {tag}"
+ lines.append(f" {escape(channel_title)}")
+ lines.append(f" {escape(base_url)}")
+ lines.append(" HuggingFace Daily Papers — 中文论文导览站")
+ lines.append(f" zh-CN")
+
+ for paper in papers:
+ title_text = paper.title_zh or paper.title_en
+ link = f"{base_url}/paper/{paper.arxiv_id}"
+
+ desc = ""
+ if paper.summary and paper.summary.one_line:
+ desc = paper.summary.one_line
+ elif paper.abstract:
+ desc = paper.abstract[:500]
+
+ pub_date = ""
+ if paper.paper_date:
+ # RFC 822 格式
+ pub_date = paper.paper_date.strftime("%a, %d %b %Y 00:00:00 +0800")
+
+ lines.append(" - ")
+ lines.append(f" {escape(title_text)}")
+ lines.append(f" {escape(link)}")
+ lines.append(f" {escape(desc)}")
+ if pub_date:
+ lines.append(f" {pub_date}")
+ lines.append(f" {escape(link)}")
+ lines.append("
")
+
+ lines.append(" ")
+ lines.append("")
+ return "\n".join(lines)
+
+
+# ── 工具函数 ──────────────────────────────────────────────────────────
+
+
+def _today_str() -> str:
+ """当前日期字符串(按 APP_TIMEZONE)。"""
+ tz = ZoneInfo(settings.APP_TIMEZONE)
+ return datetime.now(tz).strftime("%Y-%m-%d")
diff --git a/app/routes/user.py b/app/routes/user.py
new file mode 100644
index 0000000..257dd8f
--- /dev/null
+++ b/app/routes/user.py
@@ -0,0 +1,103 @@
+"""用户数据 JSON API — 收藏、阅读状态、笔记。"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import HTMLResponse
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.services.user_data import (
+ get_note,
+ save_note,
+ set_reading_status,
+ toggle_bookmark,
+)
+
+router = APIRouter(prefix="/api", tags=["user-data"])
+
+
+# ── 请求模型 ──────────────────────────────────────────────────────────
+
+
+class ReadingStatusRequest(BaseModel):
+ status: str
+
+
+class NoteRequest(BaseModel):
+ content: str
+
+
+# ── 收藏 ──────────────────────────────────────────────────────────────
+
+
+@router.post("/bookmark/{arxiv_id}")
+def bookmark_toggle(arxiv_id: str, request: Request, db: Session = Depends(get_db)):
+ """切换收藏状态。支持 HTMX 局部刷新和 JSON 响应。"""
+ result = toggle_bookmark(db, arxiv_id)
+
+ if "error" in result:
+ raise HTTPException(status_code=404, detail=result["error"])
+
+ # HTMX 请求 → 返回 HTML 片段
+ if request.headers.get("HX-Request"):
+ star = "★" if result["bookmarked"] else "☆"
+ active_class = " active" if result["bookmarked"] else ""
+ html = (
+ f'"
+ )
+ return HTMLResponse(content=html)
+
+ return result
+
+
+# ── 阅读状态 ──────────────────────────────────────────────────────────
+
+
+@router.post("/reading-status/{arxiv_id}")
+def reading_status_update(
+ arxiv_id: str,
+ body: ReadingStatusRequest,
+ db: Session = Depends(get_db),
+):
+ """更新阅读状态。"""
+ result = set_reading_status(db, arxiv_id, body.status)
+
+ if "error" in result:
+ if result["error"] == "not_found":
+ raise HTTPException(status_code=404, detail="Paper not found")
+ elif result["error"] == "invalid_status":
+ raise HTTPException(
+ status_code=422,
+ detail=f"Invalid status. Valid: {result['valid']}",
+ )
+
+ return result
+
+
+# ── 笔记 ──────────────────────────────────────────────────────────────
+
+
+@router.get("/note/{arxiv_id}")
+def note_get(arxiv_id: str, db: Session = Depends(get_db)):
+ """获取笔记。"""
+ result = get_note(db, arxiv_id)
+ if result is None:
+ raise HTTPException(status_code=404, detail="Paper not found")
+ return result
+
+
+@router.post("/note/{arxiv_id}")
+def note_save(arxiv_id: str, body: NoteRequest, db: Session = Depends(get_db)):
+ """保存笔记。"""
+ result = save_note(db, arxiv_id, body.content)
+
+ if "error" in result:
+ raise HTTPException(status_code=404, detail=result["error"])
+
+ return result
diff --git a/app/services/searcher.py b/app/services/searcher.py
new file mode 100644
index 0000000..35a0284
--- /dev/null
+++ b/app/services/searcher.py
@@ -0,0 +1,230 @@
+"""FTS5 全文搜索服务 — 关键词 + 标签筛选,命中片段高亮,分页。"""
+
+from __future__ import annotations
+
+import math
+import re
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session, joinedload
+
+from app.models import Paper
+
+# ── 输入清洗 ──────────────────────────────────────────────────────────
+
+# FTS5 查询语法中的特殊字符,用户输入时需要移除
+_FTS5_SPECIAL = re.compile(r'["{}()^+:]')
+
+
+def _sanitize_query(raw: str) -> str:
+ """清洗用户输入,生成安全的 FTS5 MATCH 表达式。
+
+ - 移除 FTS5 特殊字符
+ - 按空白拆分为 token,用 AND 连接
+ - 空字符串返回 None
+ """
+ cleaned = _FTS5_SPECIAL.sub("", raw.strip())
+ tokens = cleaned.split()
+ if not tokens:
+ return None
+ return " AND ".join(tokens)
+
+
+# ── 核心搜索 ──────────────────────────────────────────────────────────
+
+
+def search_papers(
+ db: Session,
+ *,
+ query: str | None = None,
+ tag: str | None = None,
+ sort: str = "relevance",
+ page: int = 1,
+ page_size: int = 20,
+) -> dict:
+ """FTS5 搜索论文。
+
+ 返回::
+ {
+ "results": list[Paper],
+ "snippets": dict[int, dict], # paper_id → {title_zh, abstract}
+ "total": int,
+ "page": int,
+ "total_pages": int,
+ }
+ """
+ match_expr = _sanitize_query(query) if query else None
+
+ # ── 无关键词 + 无标签 → 空结果 ──
+ if not match_expr and not tag:
+ return {
+ "results": [],
+ "snippets": {},
+ "total": 0,
+ "page": page,
+ "total_pages": 0,
+ }
+
+ # ── 构建条件性 JOIN 和 WHERE 片段 ──
+ tag_join = ""
+ tag_where = ""
+ tag_params: dict = {}
+ if tag:
+ tag_join = "JOIN paper_tags pt ON pt.paper_id = p.id"
+ tag_where = "AND pt.tag = :tag"
+ tag_params["tag"] = tag
+
+ offset = (page - 1) * page_size
+
+ if match_expr:
+ return _search_with_fts(
+ db, match_expr, tag_join, tag_where, tag_params,
+ sort, page, page_size, offset,
+ )
+ else:
+ return _search_tag_only(
+ db, tag, sort, page, page_size, offset,
+ )
+
+
+def _search_with_fts(
+ db: Session,
+ match_expr: str,
+ tag_join: str,
+ tag_where: str,
+ tag_params: dict,
+ sort: str,
+ page: int,
+ page_size: int,
+ offset: int,
+) -> dict:
+ """有关键词时的 FTS5 MATCH 搜索。"""
+ params = {"query": match_expr, "limit": page_size, "offset": offset}
+ params.update(tag_params)
+
+ order = "bm25(papers_fts)" if sort == "relevance" else "p.paper_date DESC, p.upvotes DESC"
+
+ # ── 主查询:取 ID + rank + snippet ──
+ rows_sql = text(f"""
+ SELECT
+ p.id,
+ papers_fts.rank,
+ snippet(papers_fts, 1, '', '', '...', 32) AS snippet_title_zh,
+ snippet(papers_fts, 2, '', '', '...', 32) AS snippet_abstract
+ FROM papers_fts
+ JOIN papers p ON p.id = papers_fts.rowid
+ {tag_join}
+ WHERE papers_fts MATCH :query
+ {tag_where}
+ ORDER BY {order}
+ LIMIT :limit OFFSET :offset
+ """)
+ fts_rows = db.execute(rows_sql, params).fetchall()
+
+ # ── 计数查询 ──
+ count_sql = text(f"""
+ SELECT COUNT(DISTINCT papers_fts.rowid)
+ FROM papers_fts
+ JOIN papers p ON p.id = papers_fts.rowid
+ {tag_join}
+ WHERE papers_fts MATCH :query
+ {tag_where}
+ """)
+ total = db.execute(count_sql, params).scalar() or 0
+
+ paper_ids = [row[0] for row in fts_rows]
+ snippets = {
+ row[0]: {"title_zh": row[2], "abstract": row[3]}
+ for row in fts_rows
+ }
+
+ papers = _load_papers_by_ids(db, paper_ids, sort, {row[0]: row[1] for row in fts_rows})
+
+ return {
+ "results": papers,
+ "snippets": snippets,
+ "total": total,
+ "page": page,
+ "total_pages": math.ceil(total / page_size) if total else 0,
+ }
+
+
+def _search_tag_only(
+ db: Session,
+ tag: str,
+ sort: str,
+ page: int,
+ page_size: int,
+ offset: int,
+) -> dict:
+ """只有标签筛选,无关键词。"""
+ order = "p.paper_date DESC, p.upvotes DESC" if sort == "date" else "p.paper_date DESC, p.upvotes DESC"
+
+ rows_sql = text(f"""
+ SELECT p.id
+ FROM papers p
+ JOIN paper_tags pt ON pt.paper_id = p.id
+ WHERE pt.tag = :tag
+ ORDER BY {order}
+ LIMIT :limit OFFSET :offset
+ """)
+ rows = db.execute(rows_sql, {"tag": tag, "limit": page_size, "offset": offset}).fetchall()
+
+ count_sql = text("""
+ SELECT COUNT(DISTINCT p.id)
+ FROM papers p
+ JOIN paper_tags pt ON pt.paper_id = p.id
+ WHERE pt.tag = :tag
+ """)
+ total = db.execute(count_sql, {"tag": tag}).scalar() or 0
+
+ paper_ids = [row[0] for row in rows]
+ papers = _load_papers_by_ids(db, paper_ids)
+
+ return {
+ "results": papers,
+ "snippets": {},
+ "total": total,
+ "page": page,
+ "total_pages": math.ceil(total / page_size) if total else 0,
+ }
+
+
+def _load_papers_by_ids(
+ db: Session,
+ paper_ids: list[int],
+ sort: str | None = None,
+ rank_map: dict[int, float] | None = None,
+) -> list[Paper]:
+ """根据 ID 列表加载完整 ORM 对象,保持原始排序。"""
+ if not paper_ids:
+ return []
+
+ papers = (
+ db.query(Paper)
+ .filter(Paper.id.in_(paper_ids))
+ .options(
+ joinedload(Paper.authors),
+ joinedload(Paper.tags),
+ joinedload(Paper.summary_status),
+ joinedload(Paper.bookmark),
+ joinedload(Paper.reading_status),
+ )
+ .all()
+ )
+
+ # 按 FTS rank / tag-only 原始顺序排列
+ id_order = {pid: idx for idx, pid in enumerate(paper_ids)}
+ papers.sort(key=lambda p: id_order.get(p.id, 0))
+ return papers
+
+
+# ── 辅助查询 ──────────────────────────────────────────────────────────
+
+
+def get_all_tags(db: Session) -> list[str]:
+ """返回所有不重复的标签,按字母排序。"""
+ rows = db.execute(
+ text("SELECT DISTINCT tag FROM paper_tags ORDER BY tag")
+ ).fetchall()
+ return [row[0] for row in rows]
diff --git a/app/services/user_data.py b/app/services/user_data.py
new file mode 100644
index 0000000..77cb9c9
--- /dev/null
+++ b/app/services/user_data.py
@@ -0,0 +1,115 @@
+"""用户数据服务 — 收藏、阅读状态、个人笔记。无账号体系,数据写入本地 SQLite。"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from sqlalchemy.orm import Session
+
+from app.models import Paper, UserBookmark, UserNote, UserReadingStatus
+
+# ── 收藏 ──────────────────────────────────────────────────────────────
+
+
+def toggle_bookmark(db: Session, arxiv_id: str) -> dict:
+ """切换收藏状态。返回 {"bookmarked": bool, "arxiv_id": str}。"""
+ paper = db.query(Paper).filter(Paper.arxiv_id == arxiv_id).first()
+ if not paper:
+ return {"error": "not_found"}
+
+ existing = db.query(UserBookmark).filter(UserBookmark.paper_id == paper.id).first()
+ if existing:
+ db.delete(existing)
+ db.commit()
+ return {"bookmarked": False, "arxiv_id": arxiv_id}
+ else:
+ bookmark = UserBookmark(
+ paper_id=paper.id,
+ created_at=datetime.now(timezone.utc),
+ )
+ db.add(bookmark)
+ db.commit()
+ return {"bookmarked": True, "arxiv_id": arxiv_id}
+
+
+# ── 阅读状态 ──────────────────────────────────────────────────────────
+
+VALID_STATUSES = {"unread", "skimmed", "read_summary", "read_full"}
+
+
+def set_reading_status(db: Session, arxiv_id: str, status: str) -> dict:
+ """设置阅读状态。status 必须是 unread/skimmed/read_summary/read_full。"""
+ if status not in VALID_STATUSES:
+ return {"error": "invalid_status", "valid": sorted(VALID_STATUSES)}
+
+ paper = db.query(Paper).filter(Paper.arxiv_id == arxiv_id).first()
+ if not paper:
+ return {"error": "not_found"}
+
+ now = datetime.now(timezone.utc)
+ existing = (
+ db.query(UserReadingStatus)
+ .filter(UserReadingStatus.paper_id == paper.id)
+ .first()
+ )
+ if existing:
+ existing.status = status
+ existing.updated_at = now
+ else:
+ db.add(
+ UserReadingStatus(
+ paper_id=paper.id,
+ status=status,
+ updated_at=now,
+ )
+ )
+ db.commit()
+ return {"arxiv_id": arxiv_id, "status": status}
+
+
+# ── 笔记 ──────────────────────────────────────────────────────────────
+
+
+def get_note(db: Session, arxiv_id: str) -> dict | None:
+ """获取笔记。返回 {"arxiv_id", "content", "updated_at"} 或 None(论文不存在时)。"""
+ paper = db.query(Paper).filter(Paper.arxiv_id == arxiv_id).first()
+ if not paper:
+ return None
+
+ note = db.query(UserNote).filter(UserNote.paper_id == paper.id).first()
+ if not note:
+ return {"arxiv_id": arxiv_id, "content": "", "updated_at": None}
+
+ return {
+ "arxiv_id": arxiv_id,
+ "content": note.content,
+ "updated_at": note.updated_at.isoformat() if note.updated_at else None,
+ }
+
+
+def save_note(db: Session, arxiv_id: str, content: str) -> dict:
+ """创建或更新笔记。返回 {"arxiv_id", "content", "updated_at"}。"""
+ paper = db.query(Paper).filter(Paper.arxiv_id == arxiv_id).first()
+ if not paper:
+ return {"error": "not_found"}
+
+ now = datetime.now(timezone.utc)
+ existing = db.query(UserNote).filter(UserNote.paper_id == paper.id).first()
+ if existing:
+ existing.content = content
+ existing.updated_at = now
+ else:
+ db.add(
+ UserNote(
+ paper_id=paper.id,
+ content=content,
+ created_at=now,
+ updated_at=now,
+ )
+ )
+ db.commit()
+ return {
+ "arxiv_id": arxiv_id,
+ "content": content,
+ "updated_at": now.isoformat(),
+ }
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 478bb13..ff4ed02 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -326,13 +326,233 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
border-top: 1px solid var(--border);
}
+/* ── Responsive ─────────────────────────────────────────────────── */
+/* ── Nav Search ──────────────────────────────────────────────────── */
+.nav-search {
+ display: flex;
+ align-items: center;
+ margin-right: auto;
+}
+
+.nav-search-input {
+ padding: 5px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.85rem;
+ background: var(--bg);
+ color: var(--ink);
+ width: 180px;
+ transition: border-color 0.2s;
+ font-family: var(--font-sans);
+}
+.nav-search-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+/* ── Search Page ────────────────────────────────────────────────── */
+.search-form {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.search-input {
+ flex: 1;
+ padding: 10px 16px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 1rem;
+ font-family: var(--font-sans);
+ background: var(--surface);
+ color: var(--ink);
+}
+.search-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(45, 95, 138, 0.1);
+}
+
+.search-btn {
+ padding: 10px 24px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius);
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+.search-btn:hover { background: var(--accent-hover); }
+
+/* ── Tag Filter ─────────────────────────────────────────────────── */
+.tag-filter {
+ margin-bottom: 16px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+.tag-filter-label {
+ font-size: 0.85rem;
+ color: var(--ink-light);
+}
+.tag-chip {
+ display: inline-block;
+ padding: 4px 10px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-size: 0.8rem;
+ color: var(--ink-light);
+}
+.tag-chip:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
+.tag-chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
+
+/* ── Search Meta & Sort ─────────────────────────────────────────── */
+.search-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ font-size: 0.9rem;
+ color: var(--ink-light);
+}
+.sort-toggle a {
+ color: var(--ink-light);
+ font-size: 0.85rem;
+}
+.sort-toggle a.active { color: var(--accent); font-weight: 600; }
+.sort-toggle a:hover { color: var(--accent); text-decoration: none; }
+.sort-divider { color: var(--border); margin: 0 4px; }
+
+/* ── Search Highlight ───────────────────────────────────────────── */
+mark {
+ background: #fff3cd;
+ color: var(--ink);
+ padding: 1px 2px;
+ border-radius: 2px;
+}
+
+.paper-snippet {
+ margin-top: 8px;
+ color: var(--ink-light);
+ font-size: 0.92rem;
+ line-height: 1.6;
+}
+.paper-date {
+ margin-left: 12px;
+ font-size: 0.82rem;
+ color: var(--ink-light);
+}
+
+/* ── Pagination ─────────────────────────────────────────────────── */
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 16px;
+ margin-top: 32px;
+ padding-top: 16px;
+}
+.page-btn {
+ padding: 6px 14px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.85rem;
+ color: var(--ink-light);
+}
+.page-btn:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
+.page-info {
+ font-size: 0.85rem;
+ color: var(--ink-light);
+}
+
+/* ── Reading List ───────────────────────────────────────────────── */
+.page-heading {
+ font-family: var(--font-body);
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin-bottom: 20px;
+}
+
+.reading-list-filters {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+}
+
+.filter-chip {
+ display: inline-block;
+ padding: 6px 14px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.85rem;
+ color: var(--ink-light);
+}
+.filter-chip:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
+.filter-chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
+
+/* ── Paper Card Footer (enhanced) ──────────────────────────────── */
+.paper-footer {
+ margin-top: 12px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.paper-footer-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.paper-footer-right {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* ── Bookmark Button ────────────────────────────────────────────── */
+.btn-bookmark {
+ background: none;
+ border: none;
+ font-size: 1.2rem;
+ cursor: pointer;
+ color: var(--ink-light);
+ padding: 2px 4px;
+ transition: color 0.2s;
+ line-height: 1;
+}
+.btn-bookmark:hover { color: var(--accent); }
+.btn-bookmark.active { color: #f0a500; }
+
+/* ── Reading Badge ──────────────────────────────────────────────── */
+.reading-badge {
+ font-size: 0.75rem;
+ padding: 2px 6px;
+ border-radius: 3px;
+}
+.reading-unread { background: #f0f0f0; color: #888; }
+.reading-skimmed { background: #e3f2fd; color: #1976d2; }
+.reading-read_summary { background: #e8f5e9; color: #388e3c; }
+.reading-read_full { background: #e8f5e9; color: #2e7d32; font-weight: 500; }
+
/* ── Responsive ─────────────────────────────────────────────────── */
@media (max-width: 640px) {
.container { padding: 16px; }
.nav-bar { padding: 10px 16px; }
+ .nav-search-input { width: 120px; }
.date-nav { gap: 8px; }
.date-title { font-size: 1.2rem; }
.paper-card { padding: 14px 16px; }
.detail-title { font-size: 1.3rem; }
.detail-meta { flex-direction: column; gap: 4px; }
+ .search-form { flex-direction: column; }
+ .reading-list-filters { gap: 4px; }
+ .filter-chip { padding: 4px 10px; font-size: 0.8rem; }
}
diff --git a/app/static/js/app.js b/app/static/js/app.js
index 8e8813e..5eb7142 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app.js
@@ -1 +1,18 @@
-/* app.js — 基础前端交互(HTMX 后续增强) */
+/* app.js — 基础前端交互 */
+
+// Ctrl+K 或 / 聚焦搜索框
+document.addEventListener("keydown", function (e) {
+ var input = document.querySelector(".nav-search-input");
+ if (!input) return;
+
+ // 忽略在输入框内的按键
+ if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
+
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
+ e.preventDefault();
+ input.focus();
+ } else if (e.key === "/") {
+ e.preventDefault();
+ input.focus();
+ }
+});
diff --git a/app/templates/base.html b/app/templates/base.html
index a90aea7..fe2ccb3 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -10,8 +10,11 @@