(九)让页面与接口共用查询结果
1. 复用前面学过的请求与依赖
本章选用第十三章的 FastAPI,再配第十四章已经练过的 Jinja 模板。两条响应路线都调用 query():/api/books 返回字典,由框架转成 JSON;/ 将相同字典交给模板渲染。
# 项目文件:catalog_project/src/cataloghub/web.py
from contextlib import asynccontextmanager, closing
from pathlib import Path
import sqlite3
from typing import Literal
from urllib.parse import urlencode
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .config import Settings
from .storage import SCHEMA, open_db, public, query
from .telemetry import install_logging
ROOT = Path(__file__).parent
def create_app(settings=None):
settings = settings or Settings.from_env()
@asynccontextmanager
async def lifespan(app):
# 服务只查询已迁移数据库;初始化由单独管理命令完成。
with closing(open_db(settings.database, readonly=True)) as connection:
if connection.execute("PRAGMA user_version").fetchone()[0] != SCHEMA:
raise ValueError("请先执行 catalog migrate")
yield
app = FastAPI(title="采集书目查询", lifespan=lifespan)
install_logging(app, "cataloghub")
app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")
templates = Jinja2Templates(directory=ROOT / "templates")
def database():
with closing(open_db(settings.database, readonly=True)) as connection:
connection.execute("BEGIN") # 本次计数和分页读取同一个数据库快照。
yield connection
def filters(
q: str = Query("", max_length=100),
finished: Literal["all", "0", "1"] = "all",
tag: str = Query("", max_length=30),
page: int = Query(1, ge=1),
):
return {"q": q.strip(), "finished": finished, "tag": tag.strip(), "page": page}
@app.get("/api/books")
def books(options=Depends(filters), connection=Depends(database)):
return query(connection, **options)filters() 统一处理参数,database() 管理本次读取连接。这里使用同步 SQLite,所以路由与依赖写为普通 def。lifespan 在开始接收请求前检查结构版本。静态文件路径从模块自身位置推导,启动目录改变后仍能定位安装包中的资源。
# 续写同一文件:catalog_project/src/cataloghub/web.py
@app.get("/")
def index(request: Request, options=Depends(filters), connection=Depends(database)):
result = query(connection, **options)
# 翻页只改 page,其余查询条件继续交回服务器。
links = {
name: "/?" + urlencode({**options, "page": value})
for name, value in [
("previous", max(1, options["page"] - 1)),
("next", options["page"] + 1),
]
}
return templates.TemplateResponse(
request=request,
name="index.html",
context={"result": result, "filters": options, "links": links},
)
@app.get("/books/{source_id}")
def detail(source_id: str, request: Request, connection=Depends(database)):
row = connection.execute(
"SELECT * FROM books WHERE source_id=?", (source_id,)
).fetchone()
if row is None:
raise HTTPException(404, "没有找到这本书")
return templates.TemplateResponse(
request=request, name="detail.html", context={"book": public(row)}
)
@app.get("/health/live")
def live():
return {"status": "ok"}
@app.get("/health/ready")
def ready():
try:
with closing(open_db(settings.database, readonly=True)) as connection:
version = connection.execute("PRAGMA user_version").fetchone()[0]
connection.execute("SELECT 1 FROM books LIMIT 1").fetchone()
if version != SCHEMA:
raise ValueError("schema")
except (OSError, sqlite3.Error, ValueError):
return JSONResponse({"status": "not_ready"}, status_code=503)
return {"status": "ready", "schema": version}
return app翻页用 urlencode() 重新编码查询参数。点击下一页时只改页码,关键词和未读筛选仍在地址中。详情页继续按来源编号查询,找不到记录就返回 404。
2. 模板遍历与输出
<!-- 项目文件:catalog_project/src/cataloghub/templates/index.html -->
{% extends 'base.html' %}
{% block content %}
<!-- GET 表单把当前筛选保留在地址中;翻页链接继续携带这些条件。 -->
<form class="panel filters" method="get">
<label>书名关键词<input name="q" value="{{ filters.q }}" maxlength="100"></label>
<label>阅读状态<select name="finished"><option value="all">全部</option><option value="0" {% if filters.finished == '0' %}selected{% endif %}>未读</option><option value="1" {% if filters.finished == '1' %}selected{% endif %}>已读</option></select></label>
<label>标签<input name="tag" value="{{ filters.tag }}" maxlength="30"></label><button>查询</button><a href="/">重置</a></form>
<p class="metrics">共 <strong>{{ result.total }}</strong> 本 · 第 {{ result.page }} / {{ result.pages }} 页</p>
<!-- 明确按键取出书目列表;书名由模板自动转义后显示。 -->
<section class="grid">{% for book in result['items'] %}<article class="panel book" data-id="{{ book.source_id }}">
<p class="badge">{{ '已读' if book.finished else '未读' }} · {{ book.source_id }}</p><h2><a href="/books/{{ book.source_id }}">{{ book.title }}</a></h2>
<p>{{ book.tags|join(' · ') }}</p><a href="/books/{{ book.source_id }}">查看导入记录</a></article>{% else %}<div class="panel">没有符合条件的书目,可以调整筛选。</div>{% endfor %}</section>
<nav class="pagination">{% if result.page > 1 %}<a href="{{ links.previous }}">上一页</a>{% endif %}
{% if result.page < result.pages %}<a href="{{ links.next }}">下一页</a>{% endif %}</nav>
{% endblock %}result['items'] 明确取字典里的键,避免与字典本身的 items 方法混在一起。Jinja 会转义书名中的 HTML 字符;书名即使含 <img ...>,页面也按文本显示。完整外壳见 base.html,详情见 detail.html,样式见 style.css。