优化瀑布流显示,优化docs和status显示

This commit is contained in:
inliver233 committed 2026-02-24 03:44:38 +08:00
1 parent 6a843ceafd
commit 53e08901dd
4 files changed
+164 -158

No files matched your search

+29 -5
View File
@@ -22,6 +22,10 @@ def _build_docs_html(*, base_url: str) -> str:
"img_pixiv_cat": u("/random?pixiv_cat=1"),
"img_pixiv_re": u("/random?pixiv_cat=1&pximg_mirror_host=re"),
"img_redirect": u("/random?redirect=1"),
"status": u("/status"),
"status_json": u("/status.json"),
"wtf": u("/wtf"),
"wtf_r18": u("/wtf?r18=1"),
"json_full": u("/random?format=json"),
"json_simple": u("/random?format=simple_json"),
"r18_only": u("/random?r18=1"),
@@ -35,9 +39,7 @@ def _build_docs_html(*, base_url: str) -> str:
"quality_strong": u(
"/random?strategy=quality&quality_samples=200&min_pixels=2000000&min_bookmarks=100&illust_type=illust&ai_type=0"
),
"complex": u(
"/random?format=json&r18=1&illust_type=illust&orientation=portrait&min_pixels=2500000&min_bookmarks=25&min_comments=50&included_tags=loli"
),
"complex": u("/random?r18=1&illust_type=illust&orientation=portrait&min_pixels=2500000&min_bookmarks=2&min_comments=5&included_tags=loli"),
"tags_api": u("/tags"),
"authors_api": u("/authors"),
"swagger": u("/api/docs"),
@@ -250,6 +252,8 @@ def _build_docs_html(*, base_url: str) -> str:
<p>质量优先随机 + 强力筛选,支持标签/热度/分辨率/R18/AI/作品类型等组合查询。</p>
<div class="chips" aria-label="quick-links">
<a class="chip" href="{examples["img_default"]}"><strong>/random</strong> 直接出图</a>
<a class="chip" href="{examples["wtf"]}"><strong>/wtf</strong> 瀑布流</a>
<a class="chip" href="{examples["status"]}"><strong>/status</strong> 运行状态</a>
<a class="chip" href="{examples["json_full"]}"><strong>format=json</strong> 返回 JSON</a>
<a class="chip" href="{examples["pure_random"]}"><strong>strategy=random</strong> 纯随机</a>
<a class="chip" href="{examples["swagger"]}"><strong>/api/docs</strong> Swagger</a>
@@ -294,8 +298,24 @@ def _build_docs_html(*, base_url: str) -> str:
</section>
</div>
<section class="card" style="margin-top: 14px;">
<h2>5) 状态页 / 瀑布流</h2>
<p><span class="kbd">/status</span> 为公开仪表盘:展示 API 状态、图库概览、/random 请求统计;<span class="kbd">/status.json</span> 为机器可读 JSON。</p>
<pre><code>{examples["status"]}
{examples["status_json"]}</code></pre>
<p><span class="kbd">/wtf</span> 为瀑布流:参数与 <span class="kbd">/random</span> 完全一致(例如 r18/标签/分辨率/热度等),页面内部会自动加上 <span class="kbd">adaptive=1</span> 与 <span class="kbd">redirect=1</span> 来做屏幕适配与稳定链接。</p>
<pre><code>{examples["wtf"]}
{examples["wtf_r18"]}</code></pre>
</section>
<section class="card" style="margin-top: 14px;">
<h2>筛选语法速记</h2>
<div class="note">
<strong>标签 AND / OR 速记:</strong>
<span class="kbd">AND</span> 用“重复参数”(例如 <span class="kbd">included_tags=a&amp;included_tags=b</span>),
<span class="kbd">OR</span> 用同一参数里的 <span class="kbd">|</span>(例如 <span class="kbd">included_tags=a|b</span>)。
<span class="muted">如果你的客户端不方便输入 <span class="kbd">|</span>,也可以写成 <span class="kbd">%7C</span>。</span>
</div>
<div class="table-wrap" role="region" aria-label="filters-table" tabindex="0">
<table>
<thead>
@@ -340,12 +360,16 @@ def _build_docs_html(*, base_url: str) -> str:
<td>
必须包含的标签:参数之间是 <strong>AND</strong>,单个参数内用 <code>|</code> 表示 <strong>OR</strong>。<br/>
例:<code>included_tags=girl|boy&amp;included_tags=white|black</code> 表示 (girl OR boy) AND (white OR black)。<br/>
<span class="muted">提示:URL 里的 <code>&amp;</code> 是“参数分隔符”,要写 AND 就重复参数;要写 OR 就在同一参数里用 <code>|</code>。</span><br/>
示例:<a href="{examples["tag_loli"]}">{examples["tag_loli"]}</a>
</td>
</tr>
<tr>
<td><code>excluded_tags</code></td>
<td>必须不包含的标签(OR)。可重复传参。示例:<code>excluded_tags=AI生成</code></td>
<td>
必须不包含的标签:任意命中即排除。可重复传参:<code>excluded_tags=a&amp;excluded_tags=b</code>。<br/>
<span class="muted"><code>excluded_tags=a|b</code> 等价于排除 a 或 b(效果同上)。</span>
</td>
</tr>
<tr>
<td><code>min_width</code><br/><code>min_height</code><br/><code>min_pixels</code></td>
@@ -390,7 +414,7 @@ def _build_docs_html(*, base_url: str) -> str:
<section class="card" style="margin-top: 14px;">
<h2>复杂示例(组合查询)</h2>
<p>示例:R18 + 插画 + 竖图 + 250万像素以上 + 收藏≥25 + 评论≥50 + 标签包含 loli,并返回 JSON。</p>
<p>示例:R18 + 插画 + 竖图 + 250万像素以上 + 收藏≥2 + 评论≥5 + 标签包含 loli(直接出图)。</p>
<pre><code>{examples["complex"]}</code></pre>
</section>
</div>
+29 -9
View File
@@ -32,6 +32,8 @@ from app.jobs.enqueue import enqueue_opportunistic_hydrate_metadata
router = APIRouter()
_MAX_TAG_FILTERS = 50
_MAX_TAG_OR_TERMS = 20
_MAX_TAG_TOTAL_TERMS = 200
@router.get("/images")
@@ -87,22 +89,40 @@ async def list_images(
if min_width < 0 or min_height < 0 or min_pixels < 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported min_*", status_code=400)
def _parse_tags(values: list[str] | None) -> list[str]:
def _parse_tag_filters(values: list[str] | None) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in values or []:
for part in str(raw).split("|"):
name = part.strip()
if not name or name in seen:
continue
seen.add(name)
out.append(name)
expr = str(raw or "").strip()
if not expr or expr in seen:
continue
seen.add(expr)
out.append(expr)
return out
included = _parse_tags(included_tags)
excluded = _parse_tags(excluded_tags)
def _validate_tag_filters(values: list[str]) -> None:
total_terms = 0
for expr in values:
parts: list[str] = []
seen_terms: set[str] = set()
for part in str(expr).split("|"):
term = part.strip()
if not term or term in seen_terms:
continue
seen_terms.add(term)
parts.append(term)
if len(parts) > _MAX_TAG_OR_TERMS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag terms in a group", status_code=400)
total_terms += len(parts)
if total_terms > _MAX_TAG_TOTAL_TERMS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag terms", status_code=400)
included = _parse_tag_filters(included_tags)
excluded = _parse_tag_filters(excluded_tags)
if len(included) > _MAX_TAG_FILTERS or len(excluded) > _MAX_TAG_FILTERS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag filters", status_code=400)
_validate_tag_filters(included)
_validate_tag_filters(excluded)
if user_id is not None and int(user_id) <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported user_id", status_code=400)
+4 -41
View File
@@ -48,33 +48,6 @@ async def _query_gallery_stats(engine) -> dict[str, Any]:
(await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1 AND ai_type IS NULL;")).scalar_one()
)
tag_id = (
await conn.exec_driver_sql(
"SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1;",
("loli",),
)
).scalar_one_or_none()
if tag_id is None:
loli_count = 0
loli_found = False
else:
loli_found = True
loli_count = int(
(
await conn.exec_driver_sql(
"""
SELECT COUNT(DISTINCT it.image_id)
FROM image_tags it
JOIN images i ON i.id = it.image_id
WHERE it.tag_id = ? AND i.status = 1;
""".strip(),
(int(tag_id),),
)
).scalar_one()
)
non_loli_count = max(0, int(images_enabled) - int(loli_count))
return {
"gallery": {
"images_total": images_total,
@@ -85,7 +58,6 @@ WHERE it.tag_id = ? AND i.status = 1;
"breakdown": {
"r18": {"r18": r18_count, "safe": safe_count, "unknown": r18_unknown},
"ai": {"ai": ai_count, "non_ai": non_ai_count, "unknown": ai_unknown},
"loli": {"loli": loli_count, "non_loli": non_loli_count, "tag_found": bool(loli_found)},
},
}
@@ -109,6 +81,7 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An
gallery = payload.get("gallery") if isinstance(payload.get("gallery"), dict) else {}
images_total = _clamp_int(gallery.get("images_total"))
images_enabled = _clamp_int(gallery.get("images_enabled"))
illust_total = _clamp_int(gallery.get("illust_total"))
authors_total = _clamp_int(gallery.get("authors_total"))
@@ -124,7 +97,7 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An
json_url = u("/status.json")
docs_url = u("/docs")
random_url = u("/random")
admin_url = u("/admin")
wtf_url = u("/wtf")
return f"""<!doctype html>
<html lang="zh-CN">
@@ -377,7 +350,7 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An
<div class="chips" aria-label="quick-links">
<a class="chip" href="{random_url}"><strong>/random</strong> 随机出图</a>
<a class="chip" href="{docs_url}"><strong>/docs</strong> 使用文档</a>
<a class="chip" href="{admin_url}"><strong>/admin</strong> 管理后台</a>
<a class="chip" href="{wtf_url}"><strong>/wtf</strong> 瀑布流</a>
</div>
</div>
@@ -388,7 +361,7 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An
<div class="k"><div class="label">总图片数</div><div class="val">{images_total}</div><div class="hint">images</div></div>
<div class="k"><div class="label">总作品数</div><div class="val">{illust_total}</div><div class="hint">DISTINCT illust_id</div></div>
<div class="k"><div class="label">总作者数</div><div class="val">{authors_total}</div><div class="hint">DISTINCT user_id</div></div>
<div class="k"><div class="label">更多统计</div><div class="val">—</div><div class="hint"><a href="{admin_url}">后台首页</a></div></div>
<div class="k"><div class="label">可用图片数</div><div class="val">{images_enabled}</div><div class="hint">status=1</div></div>
</div>
</section>
@@ -410,7 +383,6 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An
<select id="metric">
<option value="r18">R18 / 非 R18</option>
<option value="ai">AI / 非 AI</option>
<option value="loli">萝莉 / 非萝莉</option>
</select>
<span class="muted" id="metricNote"></span>
</div>
@@ -481,15 +453,6 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An
]);
return;
}}
if (metric === "loli") {{
const l = bd.loli || {{}};
note.textContent = (l.tag_found === false) ? "(tags 表中未找到 loli 标签)" : "(标签:loli)";
renderPie([
{{ label: "萝莉", value: l.loli || 0, color: "#c07046" }},
{{ label: "非萝莉", value: l.non_loli || 0, color: "#1f7a56" }},
]);
return;
}}
// default: r18
note.textContent = "(x_restrict)";
const r = bd.r18 || {{}};
+102 -103
View File
@@ -64,7 +64,7 @@ def _build_wtf_html(*, base_url: str) -> str:
a:hover {{ text-decoration: underline; }}
.wrap {{
max-width: 1180px;
max-width: 980px;
margin: 0 auto;
padding: 22px 16px 44px;
}}
@@ -138,29 +138,18 @@ def _build_wtf_html(*, base_url: str) -> str:
.feed {{
margin-top: 14px;
column-width: 320px;
column-gap: 14px;
}}
@media (max-width: 520px) {{
.feed {{ column-width: auto; column-count: 1; column-gap: 0; }}
display: flex;
flex-direction: column;
gap: 14px;
}}
.item {{
break-inside: avoid;
margin: 0 0 14px;
border: 1px solid var(--border);
background: var(--card);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(40, 24, 16, 0.06);
}}
.item .meta {{
padding: 8px 10px;
font-size: 12px;
color: rgba(67, 51, 44, 0.72);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
content-visibility: auto;
contain-intrinsic-size: 800px;
}}
.item img {{
width: 100%;
@@ -174,12 +163,6 @@ def _build_wtf_html(*, base_url: str) -> str:
opacity: 1;
transform: translateY(0px);
}}
.item.fail {{
border-color: rgba(180, 35, 24, 0.26);
}}
.item.fail .meta {{
color: rgba(180, 35, 24, 0.86);
}}
.sentinel {{
text-align: center;
padding: 18px 0 0;
@@ -227,91 +210,103 @@ def _build_wtf_html(*, base_url: str) -> str:
info.textContent = baseQuery ? `当前过滤: ?${{baseQuery}}` : "当前过滤: (无)";
let paused = false;
let counter = 0;
let seq = 0;
let inflight = 0;
function buildUrl() {{
const p = new URLSearchParams(baseParams);
p.set("t", String(Date.now()) + "_" + String(counter++));
return "/random?" + p.toString();
}}
function addItem() {{
const item = document.createElement("div");
item.className = "item";
const img = document.createElement("img");
img.loading = "lazy";
img.decoding = "async";
img.referrerPolicy = "no-referrer";
const meta = document.createElement("div");
meta.className = "meta";
meta.innerHTML = `<span class="muted">#${{String(counter)}}</span><span class="muted">加载中…</span>`;
let tries = 0;
const setSrc = () => {{
meta.children[1].textContent = tries > 0 ? `重试 ${{tries}}…` : "加载中…";
img.src = buildUrl();
}};
img.onload = () => {{
item.classList.add("loaded");
meta.children[1].textContent = "OK";
inflight = Math.max(0, inflight - 1);
updateSentinel();
maybePrefill();
}};
img.onerror = () => {{
tries += 1;
if (tries <= 3) {{
setTimeout(setSrc, 200 * tries);
return;
}}
item.classList.add("fail");
meta.children[1].textContent = "加载失败";
inflight = Math.max(0, inflight - 1);
updateSentinel();
maybePrefill();
}};
inflight += 1;
updateSentinel();
item.appendChild(img);
item.appendChild(meta);
feed.appendChild(item);
setSrc();
}}
function batch(n) {{
const count = Math.max(1, Number(n) || 1);
for (let i = 0; i < count; i++) addItem();
}}
let rendered = 0;
let target = 0;
function isMobile() {{
return window.matchMedia && window.matchMedia("(max-width: 520px)").matches;
}}
function updateSentinel() {{
const items = feed.children.length;
const status = paused ? "已暂停" : "加载中";
sentinel.textContent = `${{status}} · 已渲染 ${{items}} 张 · in-flight ${{inflight}}`;
function cfg() {{
const mobile = isMobile();
const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const effectiveType = conn && conn.effectiveType ? String(conn.effectiveType) : "";
const slow = effectiveType.includes("2g") || effectiveType.includes("3g");
const base = mobile
? { initial: 8, step: 6, maxInflight: 8 }
: { initial: 12, step: 10, maxInflight: 14 };
return {{
initial: base.initial,
step: base.step,
maxInflight: slow ? Math.max(4, Math.floor(base.maxInflight / 2)) : base.maxInflight,
}};
}}
function maybePrefill() {{
function buildUrl() {{
const p = new URLSearchParams(baseParams);
p.set("t", String(Date.now()) + "_" + String(seq++));
return "/random?" + p.toString();
}}
function updateSentinel() {{
const status = paused ? "已暂停" : "加载中";
sentinel.textContent = `${{status}} · 已显示 ${{rendered}} 张 · in-flight ${{inflight}}`;
}}
function appendItem(imgEl) {{
const item = document.createElement("div");
item.className = "item";
item.appendChild(imgEl);
feed.appendChild(item);
requestAnimationFrame(() => item.classList.add("loaded"));
}}
function startOne() {{
if (paused) return;
const items = feed.children.length;
const target = isMobile() ? 10 : 18;
if (items < target) {{
batch(target - items);
const c = cfg();
if (inflight >= c.maxInflight) return;
if ((rendered + inflight) >= target) return;
inflight += 1;
updateSentinel();
const img = new Image();
img.decoding = "async";
img.referrerPolicy = "no-referrer";
img.alt = "";
let tries = 0;
const load = () => {{
tries += 1;
img.src = buildUrl();
}};
img.onload = () => {{
inflight = Math.max(0, inflight - 1);
rendered += 1;
appendItem(img);
updateSentinel();
ensure();
}};
img.onerror = () => {{
if (tries < 3) {{
setTimeout(load, 250 * tries);
return;
}}
inflight = Math.max(0, inflight - 1);
updateSentinel();
ensure();
}};
load();
}}
function ensure() {{
if (paused) return;
const c = cfg();
while (inflight < c.maxInflight && (rendered + inflight) < target) {{
startOne();
}}
}}
function loadMore() {{
if (paused) return;
const maxInflight = isMobile() ? 10 : 18;
if (inflight >= maxInflight) return;
const n = isMobile() ? 6 : 10;
batch(n);
function bump() {{
const c = cfg();
target = Math.max(target, rendered + inflight + c.step);
ensure();
}}
toggle.addEventListener("click", () => {{
@@ -319,22 +314,27 @@ def _build_wtf_html(*, base_url: str) -> str:
toggle.textContent = paused ? "继续加载" : "暂停加载";
updateSentinel();
if (!paused) {{
maybePrefill();
loadMore();
ensure();
}}
}});
const io = new IntersectionObserver((entries) => {{
for (const e of entries) {{
if (e.isIntersecting) loadMore();
if (e.isIntersecting) bump();
}}
}}, {{ root: null, rootMargin: "900px 0px", threshold: 0 }});
}}, {{ root: null, rootMargin: "2200px 0px", threshold: 0 }});
io.observe(sentinel);
// Initial fill.
maybePrefill();
loadMore();
target = cfg().initial;
ensure();
updateSentinel();
window.addEventListener("resize", () => {{
const c = cfg();
target = Math.max(target, c.initial);
ensure();
}});
</script>
</body>
</html>
@@ -349,4 +349,3 @@ async def wtf_page(request: Request) -> HTMLResponse:
resp = HTMLResponse(content=html, status_code=200, headers={"Cache-Control": "no-store"})
set_request_id_header(resp, rid)
return resp