diff --git a/backend/app/api/public/random.py b/backend/app/api/public/random.py
index 5d21bf6..26efafe 100644
--- a/backend/app/api/public/random.py
+++ b/backend/app/api/public/random.py
@@ -811,6 +811,10 @@ async def random_image(
"bookmark_count": getattr(image, "bookmark_count", None),
"view_count": getattr(image, "view_count", None),
"comment_count": getattr(image, "comment_count", None),
+ "user": {
+ "id": str(image.user_id) if image.user_id is not None else None,
+ "name": image.user_name,
+ },
},
"urls": {
"proxy": f"/i/{image.id}.{image.ext}",
diff --git a/backend/app/api/public/wtf_page.py b/backend/app/api/public/wtf_page.py
index 94abdc8..a3230cf 100644
--- a/backend/app/api/public/wtf_page.py
+++ b/backend/app/api/public/wtf_page.py
@@ -129,6 +129,38 @@ def _build_wtf_html(*, base_url: str) -> str:
color: rgba(43, 29, 22, 0.90);
}}
.btn:hover {{ filter: brightness(0.98); }}
+
+ .seg {{
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--border);
+ background: rgba(43, 29, 22, 0.06);
+ border-radius: 999px;
+ padding: 2px;
+ gap: 2px;
+ }}
+ .segbtn {{
+ cursor: pointer;
+ border: 0;
+ background: transparent;
+ border-radius: 999px;
+ padding: 7px 10px;
+ font-size: 12px;
+ color: rgba(43, 29, 22, 0.78);
+ user-select: none;
+ white-space: nowrap;
+ }}
+ .segbtn:hover {{ filter: brightness(0.98); }}
+ .segbtn.active {{
+ background: var(--card-2);
+ color: rgba(43, 29, 22, 0.92);
+ box-shadow: 0 6px 18px rgba(40, 24, 16, 0.10);
+ }}
+ .segbtn:focus-visible {{
+ outline: 2px solid rgba(162, 82, 44, 0.55);
+ outline-offset: 2px;
+ }}
+
.muted {{ color: var(--muted2); }}
code {{
font-family: var(--mono);
@@ -173,6 +205,24 @@ def _build_wtf_html(*, base_url: str) -> str:
.item.pending img {{
aspect-ratio: var(--ar, 16 / 9);
}}
+ .meta {{
+ display: none;
+ padding: 8px 10px 10px;
+ border-top: 1px solid rgba(58, 38, 26, 0.10);
+ background: rgba(255, 250, 243, 0.92);
+ color: rgba(67, 51, 44, 0.78);
+ font-size: 12px;
+ line-height: 1.4;
+ gap: 6px;
+ flex-wrap: wrap;
+ }}
+ .meta a {{
+ color: var(--link);
+ text-decoration: none;
+ font-family: var(--mono);
+ font-size: 11px;
+ }}
+ .meta a:hover {{ text-decoration: underline; }}
.item img {{
width: 100%;
height: auto;
@@ -191,6 +241,119 @@ def _build_wtf_html(*, base_url: str) -> str:
color: var(--muted2);
font-size: 11px;
}}
+
+ body.view-masonry .wrap {{
+ max-width: none;
+ padding-left: 0;
+ padding-right: 0;
+ }}
+ body.view-masonry .top {{
+ margin: 0 16px;
+ }}
+ @media (max-width: 520px) {{
+ body.view-masonry .top {{ margin: 0 12px; }}
+ }}
+ body.view-masonry .feed {{
+ margin-top: 14px;
+ display: flex;
+ flex-direction: row;
+ gap: 0;
+ align-items: flex-start;
+ width: 100%;
+ }}
+ body.view-masonry .mcol {{
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ line-height: 0;
+ min-width: 0;
+ }}
+ body.view-masonry .item {{
+ width: 100%;
+ break-inside: avoid;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
+ contain-intrinsic-size: 620px;
+ }}
+ body.view-masonry .item.pending {{
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ }}
+ body.view-masonry .item img {{
+ border-radius: 0;
+ transition: opacity 180ms ease;
+ transform: none;
+ }}
+ body.view-masonry .item.loaded img {{
+ transform: none;
+ }}
+
+ body.view-tiles .feed {{
+ margin-top: 14px;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+ }}
+ @media (max-width: 1100px) {{
+ body.view-tiles .feed {{
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ }}
+ }}
+ @media (max-width: 760px) {{
+ body.view-tiles .feed {{
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ }}
+ }}
+ @media (max-width: 520px) {{
+ body.view-tiles .feed {{
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }}
+ }}
+ body.view-tiles .item {{
+ border-radius: 14px;
+ background: var(--card);
+ border: 1px solid var(--border);
+ box-shadow: 0 10px 30px rgba(40, 24, 16, 0.06);
+ transition: transform 120ms ease, box-shadow 180ms ease, border-color 180ms ease;
+ }}
+ body.view-tiles .item:hover {{
+ transform: translateY(-1px);
+ border-color: rgba(162, 82, 44, 0.30);
+ box-shadow: 0 14px 36px rgba(40, 24, 16, 0.10);
+ }}
+ body.view-tiles .item:focus-within {{
+ border-color: rgba(162, 82, 44, 0.42);
+ }}
+ body.view-tiles .item img {{
+ aspect-ratio: var(--tile-ar, 16 / 9);
+ object-fit: cover;
+ transform: none;
+ }}
+ body.view-tiles .item.pending img {{
+ aspect-ratio: var(--tile-ar, var(--ar, 16 / 9));
+ }}
+ body.view-tiles .meta {{
+ display: flex;
+ }}
+ body.view-tiles .meta a {{
+ padding: 2px 6px;
+ border-radius: 8px;
+ }}
+ body.view-tiles .meta a:hover {{
+ background: rgba(192, 112, 70, 0.14);
+ text-decoration: none;
+ }}
+ body.view-tiles .meta a:focus-visible {{
+ outline: 2px solid rgba(162, 82, 44, 0.55);
+ outline-offset: 2px;
+ }}
@@ -207,6 +370,11 @@ def _build_wtf_html(*, base_url: str) -> str:
+
+
+
+
+
@@ -220,18 +388,142 @@ def _build_wtf_html(*, base_url: str) -> str:
const sentinel = document.getElementById("sentinel");
const info = document.getElementById("info");
const toggle = document.getElementById("toggle");
+ const viewSeg = document.getElementById("viewSeg");
+ const viewButtons = Array.from(viewSeg.querySelectorAll("button[data-view]"));
- const baseParams = new URLSearchParams(window.location.search);
+ const qs = new URLSearchParams(window.location.search);
+ const VIEW_KEY = "wtf_view";
+
+ const allItems = [];
+ let masonryCols = [];
+ let masonryHeights = [];
+ let masonryCount = 0;
+
+ function normalizeView(raw) {{
+ const v = String(raw || "").trim().toLowerCase();
+ if (v === "masonry" || v === "mason" || v === "standard") return "masonry";
+ if (v === "tiles" || v === "tile" || v === "grid" || v === "small") return "tiles";
+ return "single";
+ }}
+
+ function ratioToNumber(r) {{
+ const s = String(r || "").replace(/\\s+/g, "");
+ const m = s.match(/^(\\d+(?:\\.\\d+)?)\\/(\\d+(?:\\.\\d+)?)$/);
+ if (!m) return 0.75;
+ const w = Number(m[1]);
+ const h = Number(m[2]);
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return 0.75;
+ return h / w;
+ }}
+
+ function desiredMasonryCount() {{
+ const w = Math.max(0, document.documentElement.clientWidth || window.innerWidth || 0);
+ if (w <= 520) return 1;
+ if (w <= 980) return 2;
+ return 3;
+ }}
+
+ function masonryColWidth() {{
+ const w = Math.max(320, feed.clientWidth || document.documentElement.clientWidth || window.innerWidth || 1000);
+ return masonryCount > 0 ? (w / masonryCount) : w;
+ }}
+
+ function masonryEstimateHeight(item) {{
+ const ratio = Number(item && item.dataset ? item.dataset.ratio : NaN);
+ const r = Number.isFinite(ratio) && ratio > 0 ? ratio : 0.75;
+ return masonryColWidth() * r;
+ }}
+
+ function rebuildLayout() {{
+ try {{ feed.textContent = ""; }} catch (e) {{}}
+ masonryCols = [];
+ masonryHeights = [];
+ masonryCount = 0;
+
+ if (viewMode === "masonry") {{
+ masonryCount = desiredMasonryCount();
+ masonryHeights = new Array(masonryCount).fill(0);
+ for (let i = 0; i < masonryCount; i++) {{
+ const col = document.createElement("div");
+ col.className = "mcol";
+ masonryCols.push(col);
+ feed.appendChild(col);
+ }}
+
+ for (const item of allItems) {{
+ const h = masonryEstimateHeight(item);
+ let best = 0;
+ for (let i = 1; i < masonryHeights.length; i++) {{
+ if (masonryHeights[i] < masonryHeights[best]) best = i;
+ }}
+ item.dataset.mcol = String(best);
+ item.dataset.estH = String(h);
+ masonryHeights[best] += h;
+ masonryCols[best].appendChild(item);
+ }}
+ return;
+ }}
+
+ for (const item of allItems) {{
+ try {{
+ delete item.dataset.mcol;
+ delete item.dataset.estH;
+ }} catch (e) {{}}
+ feed.appendChild(item);
+ }}
+ }}
+
+ function mountNewItem(item) {{
+ allItems.push(item);
+ if (viewMode !== "masonry") {{
+ feed.appendChild(item);
+ return;
+ }}
+
+ if (!masonryCols.length || masonryCount !== desiredMasonryCount()) {{
+ rebuildLayout();
+ return;
+ }}
+
+ const h = masonryEstimateHeight(item);
+ let best = 0;
+ for (let i = 1; i < masonryHeights.length; i++) {{
+ if (masonryHeights[i] < masonryHeights[best]) best = i;
+ }}
+ item.dataset.mcol = String(best);
+ item.dataset.estH = String(h);
+ masonryHeights[best] += h;
+ masonryCols[best].appendChild(item);
+ }}
+
+ function updateMasonryEstimate(item) {{
+ if (viewMode !== "masonry") return;
+ if (!item || !item.dataset) return;
+ const idx = Number(item.dataset.mcol);
+ if (!Number.isInteger(idx) || idx < 0 || idx >= masonryHeights.length) return;
+ const oldH = Number(item.dataset.estH);
+ const prev = Number.isFinite(oldH) && oldH > 0 ? oldH : 0;
+ const next = masonryEstimateHeight(item);
+ masonryHeights[idx] = Math.max(0, masonryHeights[idx] - prev + next);
+ item.dataset.estH = String(next);
+ }}
+
+ let storedView = "";
+ try {{ storedView = localStorage.getItem(VIEW_KEY) || ""; }} catch (e) {{}}
+
+ const baseParams = new URLSearchParams(qs);
+ baseParams.delete("view");
baseParams.delete("format");
baseParams.delete("redirect");
baseParams.delete("t");
if (!baseParams.has("adaptive")) baseParams.set("adaptive", "1");
- baseParams.set("redirect", "1");
- const baseQuery = baseParams.toString();
- info.textContent = baseQuery ? `当前过滤: ?${{baseQuery}}` : "当前过滤: (无)";
+ const showParams = new URLSearchParams(baseParams);
+ const baseQuery = showParams.toString();
+ info.textContent = baseQuery ? ("当前过滤: ?" + baseQuery) : "当前过滤: (无)";
let paused = false;
+ let viewMode = normalizeView(qs.get("view") || storedView || "single");
let seq = 0;
let inflight = 0;
let rendered = 0;
@@ -242,24 +534,7 @@ def _build_wtf_html(*, base_url: str) -> str:
return window.matchMedia && window.matchMedia("(max-width: 520px)").matches;
}}
- 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: 12, step: 8, maxInflight: 12 }}
- : {{ initial: 18, step: 14, maxInflight: 22 }};
-
- return {{
- initial: base.initial,
- step: base.step,
- maxInflight: slow ? Math.max(6, Math.floor(base.maxInflight * 0.6)) : base.maxInflight,
- }};
- }}
-
- function pickSkeletonRatio() {{
+ function pickRatioFromParams() {{
const o = String(baseParams.get("orientation") || "").trim().toLowerCase();
if (o === "square") return "1 / 1";
if (o === "portrait") return "2 / 3";
@@ -272,23 +547,149 @@ def _build_wtf_html(*, base_url: str) -> str:
return isMobile() ? "3 / 4" : "16 / 9";
}}
- function buildUrl() {{
+ 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");
+
+ let initial = mobile ? 12 : 18;
+ let step = mobile ? 8 : 14;
+ let maxInflight = mobile ? 12 : 22;
+
+ if (viewMode === "masonry") {{
+ initial = mobile ? 14 : 24;
+ step = mobile ? 10 : 16;
+ maxInflight = mobile ? 14 : 22;
+ }} else if (viewMode === "tiles") {{
+ initial = mobile ? 16 : 24;
+ step = mobile ? 10 : 14;
+ maxInflight = mobile ? 16 : 20;
+ }}
+
+ if (slow) {{
+ maxInflight = Math.max(6, Math.floor(maxInflight * 0.6));
+ }}
+
+ return {{ initial: initial, step: step, maxInflight: maxInflight }};
+ }}
+
+ function applyView(mode, persist) {{
+ viewMode = normalizeView(mode);
+ document.body.classList.toggle("view-single", viewMode === "single");
+ document.body.classList.toggle("view-masonry", viewMode === "masonry");
+ document.body.classList.toggle("view-tiles", viewMode === "tiles");
+
+ for (const b of viewButtons) {{
+ const on = String(b.dataset.view || "") === viewMode;
+ b.classList.toggle("active", on);
+ b.setAttribute("aria-selected", on ? "true" : "false");
+ }}
+
+ if (viewMode === "tiles") {{
+ document.documentElement.style.setProperty("--tile-ar", pickRatioFromParams());
+ }} else {{
+ document.documentElement.style.removeProperty("--tile-ar");
+ }}
+
+ if (persist) {{
+ try {{ localStorage.setItem(VIEW_KEY, viewMode); }} catch (e) {{}}
+ }}
+
+ rebuildLayout();
+
+ const c = cfg();
+ target = Math.max(target, c.initial);
+ ensure();
+ updateSentinel();
+ }}
+
+ for (const b of viewButtons) {{
+ b.addEventListener("click", () => {{
+ applyView(String(b.dataset.view || "single"), true);
+ }});
+ }}
+ applyView(viewMode, false);
+
+ function buildRandomJsonUrl() {{
const p = new URLSearchParams(baseParams);
+ p.set("format", "simple_json");
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 buildProxyQuery() {{
+ const qp = new URLSearchParams();
+ const pc = String(baseParams.get("pixiv_cat") || "").trim();
+ if (pc === "1") qp.set("pixiv_cat", "1");
+ const mh = String(baseParams.get("pximg_mirror_host") || "").trim();
+ if (mh) qp.set("pximg_mirror_host", mh);
+ const s = qp.toString();
+ return s ? ("?" + s) : "";
}}
- function appendItem(imgEl) {{
- const item = document.createElement("div");
- item.className = "item";
- item.appendChild(imgEl);
- feed.appendChild(item);
- requestAnimationFrame(() => item.classList.add("loaded"));
+ async function fetchRandomData() {{
+ const url = buildRandomJsonUrl();
+ const resp = await fetch(url, {{ cache: "no-store" }});
+ if (resp.status === 404) {{
+ const e = new Error("NO_MATCH");
+ e.name = "NO_MATCH";
+ throw e;
+ }}
+ if (!resp.ok) {{
+ throw new Error("HTTP_" + String(resp.status));
+ }}
+ const body = await resp.json();
+ if (!body || body.ok !== true || !body.data || !body.data.urls || !body.data.image) {{
+ throw new Error("BAD_BODY");
+ }}
+ return body.data;
+ }}
+
+ function updateSentinel() {{
+ const status = paused ? "已暂停" : "加载中";
+ sentinel.textContent = status + " · 已显示 " + String(rendered) + " 张 · in-flight " + String(inflight);
+ }}
+
+ function setMeta(meta, data) {{
+ try {{
+ meta.textContent = "";
+ const image = data && data.image ? data.image : null;
+ if (!image) return;
+
+ const illustId = String(image.illust_id || "").trim();
+ const user = image.user || null;
+ const userId = user && user.id ? String(user.id).trim() : "";
+ const userName = user && user.name ? String(user.name).trim() : "";
+
+ const links = [];
+ if (userId) {{
+ const a = document.createElement("a");
+ a.href = "https://www.pixiv.net/users/" + userId;
+ a.target = "_blank";
+ a.rel = "noreferrer noopener";
+ a.textContent = "pixiv.net/users/" + userId;
+ if (userName) a.title = userName;
+ links.push(a);
+ }}
+ if (illustId) {{
+ const a = document.createElement("a");
+ a.href = "https://www.pixiv.net/artworks/" + illustId;
+ a.target = "_blank";
+ a.rel = "noreferrer noopener";
+ a.textContent = "pixiv.net/artworks/" + illustId;
+ links.push(a);
+ }}
+
+ for (let i = 0; i < links.length; i++) {{
+ if (i > 0) {{
+ const sep = document.createElement("span");
+ sep.textContent = " · ";
+ meta.appendChild(sep);
+ }}
+ meta.appendChild(links[i]);
+ }}
+ }} catch (e) {{}}
}}
function startOne() {{
@@ -302,22 +703,29 @@ def _build_wtf_html(*, base_url: str) -> str:
const item = document.createElement("div");
item.className = "item pending";
- item.style.setProperty("--ar", pickSkeletonRatio());
+ const fallbackAr = pickRatioFromParams();
+ item.style.setProperty("--ar", fallbackAr);
+ item.dataset.ratio = String(ratioToNumber(fallbackAr));
- const img = new Image();
+ const img = document.createElement("img");
img.decoding = "async";
+ img.loading = "lazy";
img.referrerPolicy = "no-referrer";
img.alt = "";
+
+ const meta = document.createElement("div");
+ meta.className = "meta";
+
item.appendChild(img);
- feed.appendChild(item);
+ item.appendChild(meta);
+ mountNewItem(item);
let tries = 0;
- const load = () => {{
- tries += 1;
- img.src = buildUrl();
- }};
+ let done = false;
- img.onload = () => {{
+ const finishOk = () => {{
+ if (done) return;
+ done = true;
inflight = Math.max(0, inflight - 1);
rendered += 1;
failStreak = 0;
@@ -326,25 +734,97 @@ def _build_wtf_html(*, base_url: str) -> str:
updateSentinel();
ensure();
}};
- img.onerror = () => {{
- if (tries < 3) {{
- setTimeout(load, 250 * tries);
- return;
- }}
+
+ const finishFail = (reason) => {{
+ if (done) return;
+ done = true;
inflight = Math.max(0, inflight - 1);
failStreak += 1;
+ try {{
+ const idx = allItems.indexOf(item);
+ if (idx >= 0) allItems.splice(idx, 1);
+ }} catch (e) {{}}
+ try {{
+ const colIdx = Number(item.dataset.mcol);
+ const est = Number(item.dataset.estH);
+ if (Number.isInteger(colIdx) && colIdx >= 0 && colIdx < masonryHeights.length && Number.isFinite(est) && est > 0) {{
+ masonryHeights[colIdx] = Math.max(0, masonryHeights[colIdx] - est);
+ }}
+ }} catch (e) {{}}
try {{ item.remove(); }} catch (e) {{}}
updateSentinel();
+ if (String(reason || "") === "NO_MATCH") {{
+ paused = true;
+ toggle.textContent = "继续加载";
+ sentinel.textContent = "没有匹配结果:请放宽过滤条件后重试。";
+ return;
+ }}
if (failStreak >= 8) {{
paused = true;
toggle.textContent = "继续加载";
- sentinel.textContent = "连续失败较多:可能网络不稳定或当前过滤无匹配,请放宽条件后重试。";
+ sentinel.textContent = "连续失败较多:可能网络不稳定,请稍后再试或放宽条件。";
return;
}}
ensure();
}};
- load();
+ const attempt = async () => {{
+ tries += 1;
+ try {{
+ const data = await fetchRandomData();
+ setMeta(meta, data);
+ try {{
+ const iw = data && data.image ? Number(data.image.width) : NaN;
+ const ih = data && data.image ? Number(data.image.height) : NaN;
+ if (Number.isFinite(iw) && Number.isFinite(ih) && iw > 0 && ih > 0) {{
+ img.setAttribute("width", String(iw));
+ img.setAttribute("height", String(ih));
+ item.style.setProperty("--ar", String(iw) + " / " + String(ih));
+ item.dataset.ratio = String(ih / iw);
+ updateMasonryEstimate(item);
+ }}
+ }} catch (e) {{}}
+ const proxy = data && data.urls ? String(data.urls.proxy || "") : "";
+ if (!proxy || proxy[0] !== "/") throw new Error("BAD_PROXY");
+ img.src = proxy + buildProxyQuery();
+ }} catch (e) {{
+ if (done) return;
+ const name = e && e.name ? String(e.name) : "";
+ const msg = e && e.message ? String(e.message) : "";
+ if (name === "NO_MATCH" || msg === "NO_MATCH") {{
+ finishFail("NO_MATCH");
+ return;
+ }}
+ if (tries < 3) {{
+ setTimeout(() => attempt(), 250 * tries);
+ return;
+ }}
+ finishFail(msg || name || "ERROR");
+ }}
+ }};
+
+ img.onload = () => {{
+ try {{
+ if ((!img.hasAttribute("width") || !img.hasAttribute("height")) && img.naturalWidth > 0 && img.naturalHeight > 0) {{
+ img.setAttribute("width", String(img.naturalWidth));
+ img.setAttribute("height", String(img.naturalHeight));
+ item.style.setProperty("--ar", String(img.naturalWidth) + " / " + String(img.naturalHeight));
+ item.dataset.ratio = String(img.naturalHeight / img.naturalWidth);
+ updateMasonryEstimate(item);
+ }}
+ }} catch (e) {{}}
+ finishOk();
+ }};
+ img.onerror = () => {{
+ if (done) return;
+ if (tries < 3) {{
+ setTimeout(() => attempt(), 250 * tries);
+ return;
+ }}
+ finishFail("IMAGE_ERROR");
+ }};
+
+ attempt();
}}
function ensure() {{
@@ -402,6 +882,9 @@ def _build_wtf_html(*, base_url: str) -> str:
window.addEventListener("resize", () => {{
const c = cfg();
target = Math.max(target, c.initial);
+ if (viewMode === "masonry" && masonryCount !== desiredMasonryCount()) {{
+ rebuildLayout();
+ }}
ensure();
}});