Compare commits
15 Commits
1e270f0055
...
c1610ace99
| Author | SHA1 | Date | |
|---|---|---|---|
| c1610ace99 | |||
| 8bdc3553bd | |||
| 940236d84f | |||
| cb8c42e833 | |||
| 8b1d55613e | |||
| 75b9080df8 | |||
| f88d83b0ad | |||
| bace31933f | |||
| 5d2b6e502d | |||
| ab4f006107 | |||
| a5c7e64230 | |||
| fe58d25b54 | |||
| e68b77aac0 | |||
| 975e331dd5 | |||
| ef6a3f0da2 |
2
Makefile
2
Makefile
@ -27,6 +27,8 @@ build-editor-incremental:
|
||||
@cd libs/tiptap-editor && npm run build
|
||||
|
||||
dev: build-editor-incremental
|
||||
@echo "Cleaning static/..."
|
||||
@rm -rf static/
|
||||
@echo "Building Tiptap editor (incremental)..."
|
||||
@echo "Starting tailwindcss watch and dx serve..."
|
||||
@tailwindcss -i input.css -o public/style.css --watch & \
|
||||
|
||||
202
input.css
202
input.css
@ -11,7 +11,7 @@
|
||||
--color-paper-secondary: #78716c;
|
||||
--color-paper-tertiary: #d6d3d1;
|
||||
--color-paper-content: #292524;
|
||||
--color-paper-code-block: #1c1917;
|
||||
--color-paper-code-block: #eff1f5;
|
||||
--color-paper-code-bg: #f5f4f0;
|
||||
--color-paper-border: #e7e5e0;
|
||||
--color-paper-accent: #5c7a5e;
|
||||
@ -45,7 +45,7 @@
|
||||
--color-paper-secondary: #8c8981;
|
||||
--color-paper-tertiary: #3a3a3d;
|
||||
--color-paper-content: #d4d2cc;
|
||||
--color-paper-code-block: #1e1f22;
|
||||
--color-paper-code-block: #1e1e2e;
|
||||
--color-paper-code-bg: #28292d;
|
||||
--color-paper-border: #2a2b2e;
|
||||
--color-paper-accent: #7da97f;
|
||||
@ -198,10 +198,15 @@
|
||||
margin-bottom: var(--content-gap-paper);
|
||||
}
|
||||
|
||||
.entry-cover img {
|
||||
/* 封面用 blur-img 结构:去 margin、保持圆角,内层 img cover 填充 */
|
||||
.entry-cover .blur-img {
|
||||
margin: 0;
|
||||
border-radius: var(--radius-paper);
|
||||
}
|
||||
.entry-cover img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
@ -521,113 +526,90 @@
|
||||
text-underline-offset: 0.3rem;
|
||||
}
|
||||
|
||||
.image-viewer-overlay {
|
||||
/* ========== 统一灯箱(Medium 风格) ========== */
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.image-viewer-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.image-viewer-img {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-paper);
|
||||
}
|
||||
|
||||
.image-viewer-close {
|
||||
position: absolute;
|
||||
top: -2.5rem;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
||||
.image-viewer-close:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.md-image-lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.md-image-lightbox-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.md-image-lightbox-content img {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-paper);
|
||||
}
|
||||
|
||||
.md-image-lightbox-close {
|
||||
position: absolute;
|
||||
top: -2.5rem;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
||||
.md-image-lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.post-card-cover {
|
||||
/* overflow:hidden 裁剪超大的 absolute img,防止原图(可能数千 px)
|
||||
撑大文档可滚动区,否则会触发非预期的 scroll 事件。 */
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-paper);
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.post-card-cover img {
|
||||
/* 灯箱图片:绝对定位,尺寸/位置由 JS 的 transform 控制。
|
||||
transform-origin 为 top left,配合 translate+scale 实现原地缩放。
|
||||
显式 width/height 由 JS 设置为原图尺寸,确保布局尺寸确定,
|
||||
transform 只做视觉缩放,不改变占位。 */
|
||||
.lightbox-img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: top left;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.lightbox-caption {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 2rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.95rem;
|
||||
padding: 0 2rem;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.lightbox-counter {
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.85rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.lightbox-nav {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
width: 3rem;
|
||||
height: 5rem;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
border-radius: 0.5rem;
|
||||
transition: color 0.2s ease-out, background 0.2s ease-out;
|
||||
}
|
||||
.lightbox-nav:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.lightbox-prev { left: 1rem; }
|
||||
.lightbox-next { right: 1rem; }
|
||||
|
||||
/* 卡片封面:blur-img 结构,固定 4:3 比例,hover 缩放 */
|
||||
.post-card-cover-blur {
|
||||
margin: 0;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
.post-card-cover-blur img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
.post-card-cover:hover img {
|
||||
.group:hover .post-card-cover-blur img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@ -745,9 +727,13 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 重置 .md-content img 的 margin/max-width/height,避免把 absolute 图层推偏 */
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
/* 20px 占位图放大后模糊,scale 遮边缘 */
|
||||
filter: blur(20px) saturate(1.2);
|
||||
transform: scale(1.1);
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.blur-img-full {
|
||||
position: absolute;
|
||||
@ -755,13 +741,25 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* 重置 .md-content img 的 margin/max-width/height,避免把 absolute 图层推偏 */
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
.blur-img-full.is-loaded {
|
||||
/* full 加载完成后由 JS 直接设 opacity:1(不依赖 CSS transition,
|
||||
避免合成层重绘时机导致 opacity 卡在 0)。 */
|
||||
.blur-img-full.is-loaded,
|
||||
.blur-img.is-loaded .blur-img-full {
|
||||
opacity: 1;
|
||||
}
|
||||
/* 容器标记 loaded 后:显式隐藏占位图 + 去掉灰底,避免重绘时机导致残留/灰边 */
|
||||
.blur-img.is-loaded .blur-img-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
.blur-img.is-loaded {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 暗色模式灰底 */
|
||||
.dark .blur-img {
|
||||
|
||||
540
public/js/lightbox.js
Normal file
540
public/js/lightbox.js
Normal file
@ -0,0 +1,540 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ============ 工具函数 ============
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return (
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
// 读取元素当前在视口里的 rect(用于飞行起点/终点)。
|
||||
// 统一映射成 {x,y,w,h}:getBoundingClientRect 返回的 DOMRect 用
|
||||
// left/top/width/height,而 fitCentered/transformFor 用 x/y/w/h,
|
||||
// 这里转成一致格式,避免 .w 读到 undefined。
|
||||
function rectOf(el) {
|
||||
var r = el.getBoundingClientRect();
|
||||
return {
|
||||
x: r.left,
|
||||
y: r.top,
|
||||
w: r.width,
|
||||
h: r.height,
|
||||
};
|
||||
}
|
||||
|
||||
// 计算图片在视口居中、contain 适配后的目标 rect。
|
||||
// naturalW/H: 图片真实像素尺寸;vw/vh: 视口尺寸。
|
||||
function fitCentered(naturalW, naturalH, vw, vh) {
|
||||
var maxW = vw * 0.92;
|
||||
var maxH = vh * 0.88;
|
||||
var scale = Math.min(maxW / naturalW, maxH / naturalH, 1);
|
||||
var w = naturalW * scale;
|
||||
var h = naturalH * scale;
|
||||
return {
|
||||
x: (vw - w) / 2,
|
||||
y: (vh - h) / 2,
|
||||
w: w,
|
||||
h: h,
|
||||
};
|
||||
}
|
||||
|
||||
// 把目标 rect 转成 transform 字符串。
|
||||
// baseW/baseH 是 img 元素的布局尺寸(=居中目标尺寸),scale 相对它缩放。
|
||||
// transform-origin 为 top left(见 CSS),translate 到 rect 左上角后 scale。
|
||||
// - 居中态:scale=1(base 就是居中尺寸)
|
||||
// - originRect 态:scale = originRect.w / base.w(缩小)
|
||||
function transformFor(rect, baseW, baseH) {
|
||||
var sx = baseW > 0 ? rect.w / baseW : 1;
|
||||
var sy = baseH > 0 ? rect.h / baseH : 1;
|
||||
return (
|
||||
"translate(" +
|
||||
rect.x +
|
||||
"px," +
|
||||
rect.y +
|
||||
"px) scale(" +
|
||||
sx +
|
||||
"," +
|
||||
sy +
|
||||
")"
|
||||
);
|
||||
}
|
||||
|
||||
// 原图 URL = data-src 去 query。data-src 形如 "/uploads/x.webp?w=800"。
|
||||
function originalUrl(dataSrc) {
|
||||
return (dataSrc || "").split("?")[0];
|
||||
}
|
||||
|
||||
// ============ 懒加载 ============
|
||||
|
||||
// 为单个 .blur-img 容器初始化高清图懒加载。
|
||||
// IO 进入视口后把 data-src 写入 src,加载完成加 is-loaded 触发 CSS 淡入。
|
||||
function initLazyLoad(container) {
|
||||
var fullImg = container.querySelector(".blur-img-full");
|
||||
if (!fullImg) return;
|
||||
if (container.getAttribute("data-blur-init")) return;
|
||||
container.setAttribute("data-blur-init", "true");
|
||||
|
||||
var fullSrc = fullImg.getAttribute("data-src");
|
||||
if (!fullSrc) return;
|
||||
|
||||
var onFullLoaded = function () {
|
||||
// 给容器加 is-loaded,CSS 据此显式隐藏 placeholder。
|
||||
// 直接把 full 层 opacity 设为 1(清掉 transition),不依赖 CSS 的 opacity
|
||||
// 过渡:合成层重绘时机不稳定,可能导致 full 层卡在 opacity:0,直到一次
|
||||
// 强制重排才更新。
|
||||
container.classList.add("is-loaded");
|
||||
fullImg.style.transition = "none";
|
||||
fullImg.style.opacity = "1";
|
||||
};
|
||||
fullImg.addEventListener("load", onFullLoaded);
|
||||
// 缓存兜底:若设 src 时图片已在缓存(load 几乎立即触发,可能早于监听注册),
|
||||
// 用 complete 补一次。注意无 src 的 img complete 也为 true,故先判 src。
|
||||
if (fullImg.getAttribute("src") && fullImg.complete) {
|
||||
onFullLoaded();
|
||||
}
|
||||
|
||||
if ("IntersectionObserver" in window) {
|
||||
var io = new IntersectionObserver(
|
||||
function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
fullImg.src = fullSrc;
|
||||
io.unobserve(container);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
io.observe(container);
|
||||
} else {
|
||||
fullImg.src = fullSrc;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 图像收集 ============
|
||||
|
||||
// 收集所有 selectors命中的 .blur-img 节点。
|
||||
// gallery: 正文图(组成图集);singles: 带 lightbox-single class 的单张图(如封面)。
|
||||
function collectImages(roots) {
|
||||
var gallery = [];
|
||||
var singles = [];
|
||||
for (var i = 0; i < roots.length; i++) {
|
||||
var nodes = roots[i].querySelectorAll(".blur-img");
|
||||
for (var j = 0; j < nodes.length; j++) {
|
||||
var n = nodes[j];
|
||||
if (n.classList.contains("lightbox-single")) {
|
||||
singles.push(n);
|
||||
} else {
|
||||
gallery.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { gallery: gallery, singles: singles };
|
||||
}
|
||||
|
||||
// ============ 灯箱状态与开/关 ============
|
||||
|
||||
// 当前灯箱状态(同时只允许一个灯箱)。
|
||||
var state = null;
|
||||
|
||||
function openLightbox(originNode, gallery, index) {
|
||||
if (state) closeLightbox(true);
|
||||
|
||||
var fullImg = originNode.querySelector(".blur-img-full");
|
||||
if (!fullImg) return;
|
||||
var dataSrc = fullImg.getAttribute("data-src") || "";
|
||||
var origSrc = originalUrl(dataSrc);
|
||||
var altText = fullImg.getAttribute("alt") || "";
|
||||
var isSingle =
|
||||
originNode.classList.contains("lightbox-single") ||
|
||||
gallery.length === 0;
|
||||
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
|
||||
// 建 DOM
|
||||
var overlay = document.createElement("div");
|
||||
overlay.className = "lightbox-overlay";
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute("aria-label", "图片预览");
|
||||
overlay.setAttribute("tabindex", "-1");
|
||||
// 创建后立即设 opacity 0:append 到 DOM 时是透明的,避免在图片加载期间
|
||||
// (start() 执行前)显示全黑背景造成闪烁。start() 的渐变会把 opacity 升到 1。
|
||||
overlay.style.opacity = "0";
|
||||
|
||||
var img = document.createElement("img");
|
||||
img.className = "lightbox-img";
|
||||
img.setAttribute("alt", altText);
|
||||
// 加载前先占 0 尺寸,避免原图(可能数千 px)在加载期间撑大文档
|
||||
// 可滚动区、触发非预期的 scroll 事件。start() 拿到 natural 尺寸后再设真实值。
|
||||
img.style.width = "0px";
|
||||
img.style.height = "0px";
|
||||
|
||||
var caption = document.createElement("figcaption");
|
||||
caption.className = "lightbox-caption";
|
||||
caption.textContent = altText;
|
||||
if (!altText) caption.style.display = "none";
|
||||
|
||||
var counter = document.createElement("div");
|
||||
counter.className = "lightbox-counter";
|
||||
if (isSingle || gallery.length === 0) {
|
||||
counter.style.display = "none";
|
||||
} else {
|
||||
counter.textContent = index + 1 + " / " + gallery.length;
|
||||
}
|
||||
|
||||
// 图集模式(>1 张)才加左右导航箭头;单张不显示。
|
||||
var prevBtn = null;
|
||||
var nextBtn = null;
|
||||
if (!isSingle && gallery.length > 1) {
|
||||
prevBtn = document.createElement("button");
|
||||
prevBtn.className = "lightbox-nav lightbox-prev";
|
||||
prevBtn.setAttribute("type", "button");
|
||||
prevBtn.setAttribute("aria-label", "上一张");
|
||||
prevBtn.textContent = "\u2039";
|
||||
|
||||
nextBtn = document.createElement("button");
|
||||
nextBtn.className = "lightbox-nav lightbox-next";
|
||||
nextBtn.setAttribute("type", "button");
|
||||
nextBtn.setAttribute("aria-label", "下一张");
|
||||
nextBtn.textContent = "\u203a";
|
||||
}
|
||||
|
||||
overlay.appendChild(img);
|
||||
overlay.appendChild(caption);
|
||||
overlay.appendChild(counter);
|
||||
if (prevBtn) overlay.appendChild(prevBtn);
|
||||
if (nextBtn) overlay.appendChild(nextBtn);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
state = {
|
||||
overlay: overlay,
|
||||
img: img,
|
||||
caption: caption,
|
||||
counter: counter,
|
||||
prevBtn: prevBtn,
|
||||
nextBtn: nextBtn,
|
||||
originNode: originNode,
|
||||
gallery: gallery,
|
||||
index: index,
|
||||
isSingle: isSingle,
|
||||
openScrollY: window.scrollY,
|
||||
origSrc: origSrc,
|
||||
altText: altText,
|
||||
closing: false,
|
||||
reduced: prefersReducedMotion(),
|
||||
scrollHandler: null,
|
||||
keyHandler: null,
|
||||
};
|
||||
|
||||
// 焦点移入灯箱
|
||||
overlay.focus();
|
||||
// 立即绑定交互(不等图片加载):加载期间 Esc/滚动/点背景也须可关闭。
|
||||
bindInteractions();
|
||||
|
||||
// 图片加载后再做动画(naturalW/H 要等加载)
|
||||
var start = function () {
|
||||
if (!state) return; // 加载前可能已被关闭
|
||||
var naturalW = img.naturalWidth || img.clientWidth || 1;
|
||||
var naturalH = img.naturalHeight || img.clientHeight || 1;
|
||||
var originRect = rectOf(originNode);
|
||||
|
||||
// 基准 = originRect(文章里图片的实际尺寸)。
|
||||
// img 的布局尺寸固定为 originRect,transform 的 scale 相对它缩放:
|
||||
// 首帧(文章位置)scale=1,居中态 scale=target.w/originRect.w。
|
||||
// 这样无论 target 比 originRect 大或小,动画都是「从文章图原样连续缩放」,
|
||||
// 视觉上是原地展开,不会像「从外面飞来」。灯箱图尺寸恒为视口最大(fitCentered)。
|
||||
var target = fitCentered(naturalW, naturalH, vw, vh);
|
||||
var baseW = originRect.w;
|
||||
var baseH = originRect.h;
|
||||
// 存基准与目标,供关闭/滚动关闭复用同一对(保证 scale 连续)。
|
||||
state.target = target;
|
||||
state.baseW = baseW;
|
||||
state.baseH = baseH;
|
||||
img.style.width = baseW + "px";
|
||||
img.style.height = baseH + "px";
|
||||
|
||||
// reduced-motion:直接淡入居中
|
||||
if (state.reduced) {
|
||||
img.style.opacity = "0";
|
||||
img.style.transform = transformFor(target, baseW, baseH);
|
||||
img.style.left = "0";
|
||||
img.style.top = "0";
|
||||
overlay.style.opacity = "0";
|
||||
// 下一帧淡入
|
||||
requestAnimationFrame(function () {
|
||||
if (!state) return;
|
||||
overlay.style.transition = "opacity 200ms ease-out";
|
||||
img.style.transition = "opacity 200ms ease-out";
|
||||
overlay.style.opacity = "1";
|
||||
img.style.opacity = "1";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 首帧:文章位置 + 原尺寸(scale=1),透明,且关闭 transition
|
||||
img.style.transition = "none";
|
||||
img.style.left = "0";
|
||||
img.style.top = "0";
|
||||
img.style.transform = transformFor(originRect, baseW, baseH);
|
||||
img.style.opacity = "0";
|
||||
overlay.style.opacity = "0";
|
||||
// 强制 reflow,确保首帧的 transform 已提交到渲染层。
|
||||
// 否则单层 rAF 里浏览器可能合并首帧与目标帧,动画从错误位置起跳。
|
||||
void img.offsetHeight;
|
||||
|
||||
// double-rAF:第一帧绘制首帧(无动画),第二帧才启动 transition 到居中。
|
||||
requestAnimationFrame(function () {
|
||||
if (!state) return;
|
||||
requestAnimationFrame(function () {
|
||||
if (!state) return;
|
||||
img.style.transition =
|
||||
"transform 250ms ease-out, opacity 250ms ease-out";
|
||||
overlay.style.transition = "opacity 250ms ease-out";
|
||||
img.style.transform = transformFor(target, baseW, baseH);
|
||||
img.style.opacity = "1";
|
||||
overlay.style.opacity = "1";
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (img.complete && img.naturalWidth) {
|
||||
start();
|
||||
} else {
|
||||
img.addEventListener("load", start, { once: true });
|
||||
}
|
||||
img.src = origSrc;
|
||||
}
|
||||
|
||||
function closeLightbox(immediate) {
|
||||
if (!state || state.closing) return;
|
||||
state.closing = true;
|
||||
cleanupInteractions();
|
||||
|
||||
var s = state;
|
||||
|
||||
// 基准 = originRect 尺寸(与打开时一致),scale 相对它缩放。
|
||||
var baseW = s.baseW || (s.target ? s.target.w : 1);
|
||||
var baseH = s.baseH || (s.target ? s.target.h : 1);
|
||||
var originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况
|
||||
|
||||
if (s.reduced || immediate) {
|
||||
removeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// 飞回 originRect:scale 从 1 缩到 originRect.w/baseW
|
||||
s.img.style.transition =
|
||||
"transform 250ms ease-out, opacity 250ms ease-out";
|
||||
s.overlay.style.transition = "opacity 250ms ease-out";
|
||||
s.img.style.transform = transformFor(originRect, baseW, baseH);
|
||||
s.img.style.opacity = "0";
|
||||
s.overlay.style.opacity = "0";
|
||||
|
||||
var done = function () {
|
||||
removeOverlay();
|
||||
};
|
||||
// 250ms 兜底,避免 transitionend 不触发
|
||||
var timer = setTimeout(done, 280);
|
||||
s.img.addEventListener("transitionend", function () { clearTimeout(timer); done(); }, { once: true });
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
if (!state) return;
|
||||
var prev = state.originNode;
|
||||
if (state.overlay && state.overlay.parentNode) {
|
||||
state.overlay.parentNode.removeChild(state.overlay);
|
||||
}
|
||||
state = null;
|
||||
// 焦点归还:.blur-img 是 span 不可聚焦,让其内部 full img 获得焦点
|
||||
if (prev) {
|
||||
var f = prev.querySelector(".blur-img-full");
|
||||
if (f) {
|
||||
f.setAttribute("tabindex", "-1");
|
||||
f.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 图集切换 ============
|
||||
|
||||
// 图集切换:淡入淡出,不飞行。newIndex 循环(首尾衔接)。
|
||||
function gotoIndex(newIndex) {
|
||||
if (!state || state.isSingle) return;
|
||||
var s = state;
|
||||
if (!s.gallery || s.gallery.length === 0) return;
|
||||
if (newIndex < 0) newIndex = s.gallery.length - 1;
|
||||
if (newIndex >= s.gallery.length) newIndex = 0;
|
||||
if (newIndex === s.index) return;
|
||||
|
||||
var newNode = s.gallery[newIndex];
|
||||
var fullImg = newNode.querySelector(".blur-img-full");
|
||||
if (!fullImg) return;
|
||||
var origSrc = originalUrl(fullImg.getAttribute("data-src") || "");
|
||||
var altText = fullImg.getAttribute("alt") || "";
|
||||
|
||||
// 淡出当前图
|
||||
s.img.style.transition = "opacity 150ms ease-out";
|
||||
s.img.style.opacity = "0";
|
||||
|
||||
// 150ms 后换图淡入
|
||||
var swap = function () {
|
||||
if (!state) return; // 切换中可能已关闭
|
||||
var fade = function () {
|
||||
if (!state) return;
|
||||
s.img.style.transition = "opacity 150ms ease-out";
|
||||
s.img.style.opacity = "1";
|
||||
};
|
||||
if (s.img.complete && s.img.naturalWidth) {
|
||||
// 缓存命中,直接淡入
|
||||
s.img.src = origSrc;
|
||||
fade();
|
||||
} else {
|
||||
s.img.addEventListener("load", fade, { once: true });
|
||||
s.img.src = origSrc;
|
||||
}
|
||||
s.caption.textContent = altText;
|
||||
s.caption.style.display = altText ? "" : "none";
|
||||
s.counter.textContent = newIndex + 1 + " / " + s.gallery.length;
|
||||
// 更新 originNode 为新图,使后续关闭/滚动关闭飞回新图位置
|
||||
s.originNode = newNode;
|
||||
s.index = newIndex;
|
||||
s.openScrollY = window.scrollY; // 重置滚动关闭基线
|
||||
};
|
||||
setTimeout(swap, 150);
|
||||
}
|
||||
|
||||
// ============ 交互绑定 ============
|
||||
|
||||
function bindInteractions() {
|
||||
var s = state;
|
||||
if (!s) return;
|
||||
|
||||
// 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
|
||||
s.overlay.addEventListener("click", function (ev) {
|
||||
if (state && ev.target === state.overlay) closeLightbox(false);
|
||||
});
|
||||
|
||||
// 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
|
||||
// 关键:逐帧读 originNode 实时 rect,文章滚多少图就回多少。
|
||||
s.scrollHandler = function () {
|
||||
if (!state) return;
|
||||
var st = state;
|
||||
if (st.closing) return;
|
||||
var dy = Math.abs(window.scrollY - st.openScrollY);
|
||||
if (st.reduced) {
|
||||
// reduced-motion:立即关
|
||||
closeLightbox(true);
|
||||
return;
|
||||
}
|
||||
var progress = Math.min(dy / 120, 1);
|
||||
var target = st.target;
|
||||
var baseW = st.baseW || (target ? target.w : 1);
|
||||
var baseH = st.baseH || (target ? target.h : 1);
|
||||
var originRect = rectOf(st.originNode);
|
||||
// 在 originRect 与居中 target 之间按 progress 线性插值
|
||||
var cur = {
|
||||
x: target.x + (originRect.x - target.x) * progress,
|
||||
y: target.y + (originRect.y - target.y) * progress,
|
||||
w: target.w + (originRect.w - target.w) * progress,
|
||||
h: target.h + (originRect.h - target.h) * progress,
|
||||
};
|
||||
st.img.style.transition = "none";
|
||||
st.img.style.transform = transformFor(cur, baseW, baseH);
|
||||
st.img.style.opacity = String(1 - progress);
|
||||
st.overlay.style.opacity = String(1 - progress);
|
||||
if (progress >= 1) {
|
||||
// 已飞回原位:文章停在当前滚动位置,移除灯箱
|
||||
st.closing = true;
|
||||
cleanupInteractions();
|
||||
removeOverlay();
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", s.scrollHandler, { passive: true });
|
||||
|
||||
// 键盘:Esc 关;图集模式 ←→ 切换
|
||||
s.keyHandler = function (ev) {
|
||||
if (!state) return;
|
||||
if (ev.key === "Escape") {
|
||||
closeLightbox(false);
|
||||
} else if (!state.isSingle && state.gallery.length > 1) {
|
||||
if (ev.key === "ArrowLeft") {
|
||||
ev.preventDefault();
|
||||
gotoIndex(state.index - 1);
|
||||
} else if (ev.key === "ArrowRight") {
|
||||
ev.preventDefault();
|
||||
gotoIndex(state.index + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", s.keyHandler);
|
||||
|
||||
// 图集导航箭头点击(stopPropagation 防止冒泡到 overlay 触发关闭)
|
||||
if (s.prevBtn) {
|
||||
s.prevBtn.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (state) gotoIndex(state.index - 1);
|
||||
});
|
||||
}
|
||||
if (s.nextBtn) {
|
||||
s.nextBtn.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (state) gotoIndex(state.index + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupInteractions() {
|
||||
if (!state) return;
|
||||
if (state.scrollHandler) {
|
||||
window.removeEventListener("scroll", state.scrollHandler);
|
||||
state.scrollHandler = null;
|
||||
}
|
||||
if (state.keyHandler) {
|
||||
document.removeEventListener("keydown", state.keyHandler);
|
||||
state.keyHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 初始化入口 ============
|
||||
|
||||
window.__initLightbox = function (selectors) {
|
||||
// selectors 可以是字符串、字符串数组
|
||||
var sels = Array.isArray(selectors) ? selectors : [selectors];
|
||||
var roots = [];
|
||||
for (var i = 0; i < sels.length; i++) {
|
||||
var found = document.querySelectorAll(sels[i]);
|
||||
for (var j = 0; j < found.length; j++) roots.push(found[j]);
|
||||
}
|
||||
|
||||
// 先对所有图片做懒加载(图集与单张都做)
|
||||
var collected = collectImages(roots);
|
||||
var everyone = collected.gallery.concat(collected.singles);
|
||||
for (var k = 0; k < everyone.length; k++) {
|
||||
initLazyLoad(everyone[k]);
|
||||
}
|
||||
|
||||
// 正文图:带 index
|
||||
var gallery = collected.gallery;
|
||||
for (var g = 0; g < gallery.length; g++) {
|
||||
(function (node, idx) {
|
||||
node.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
openLightbox(node, gallery, idx);
|
||||
});
|
||||
})(gallery[g], g);
|
||||
}
|
||||
// 单张图(封面):index = null,gallery 传空数组表示单张
|
||||
for (var si = 0; si < collected.singles.length; si++) {
|
||||
(function (node) {
|
||||
node.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
openLightbox(node, [], null);
|
||||
});
|
||||
})(collected.singles[si]);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@ -40,101 +40,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function closeLightbox(overlay) {
|
||||
if (overlay && overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
}
|
||||
|
||||
function initImageZoom(root) {
|
||||
var containers = root.querySelectorAll(".blur-img");
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
var container = containers[i];
|
||||
if (container.getAttribute("data-blur-init")) continue;
|
||||
container.setAttribute("data-blur-init", "true");
|
||||
|
||||
var fullImg = container.querySelector(".blur-img-full");
|
||||
if (!fullImg) continue;
|
||||
var fullSrc = fullImg.getAttribute("data-src");
|
||||
if (!fullSrc) continue;
|
||||
|
||||
// 加载高清图:onload 后加 is-loaded 触发 CSS opacity 淡入
|
||||
fullImg.addEventListener("load", function () {
|
||||
this.classList.add("is-loaded");
|
||||
});
|
||||
|
||||
// 懒加载:进入视口才设 src
|
||||
if ("IntersectionObserver" in window) {
|
||||
var io = new IntersectionObserver(
|
||||
function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (entry.isIntersecting) {
|
||||
fullImg.src = fullSrc;
|
||||
io.unobserve(container);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
io.observe(container);
|
||||
} else {
|
||||
// 不支持 IO:直接加载
|
||||
fullImg.src = fullSrc;
|
||||
}
|
||||
|
||||
// 灯箱:点击用高清图 URL 放大
|
||||
(function (src, altText) {
|
||||
container.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var overlay = document.createElement("div");
|
||||
overlay.className = "md-image-lightbox-overlay";
|
||||
|
||||
var containerEl = document.createElement("div");
|
||||
containerEl.className = "md-image-lightbox-content";
|
||||
|
||||
var bigImg = document.createElement("img");
|
||||
bigImg.src = src;
|
||||
bigImg.alt = altText;
|
||||
|
||||
var closeBtn = document.createElement("button");
|
||||
closeBtn.className = "md-image-lightbox-close";
|
||||
closeBtn.textContent = "\u2715";
|
||||
|
||||
containerEl.appendChild(bigImg);
|
||||
containerEl.appendChild(closeBtn);
|
||||
overlay.appendChild(containerEl);
|
||||
document.body.appendChild(overlay);
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
var onKey = function (ev) {
|
||||
if (ev.key === "Escape") {
|
||||
cleanup(overlay, onKey);
|
||||
}
|
||||
};
|
||||
var cleanup = function (ol, kh) {
|
||||
closeLightbox(ol);
|
||||
document.removeEventListener("keydown", kh);
|
||||
};
|
||||
overlay.addEventListener("click", function () {
|
||||
cleanup(overlay, onKey);
|
||||
});
|
||||
containerEl.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
closeBtn.addEventListener("click", function () {
|
||||
cleanup(overlay, onKey);
|
||||
});
|
||||
document.addEventListener("keydown", onKey);
|
||||
});
|
||||
})(fullSrc, fullImg.getAttribute("alt") || "");
|
||||
}
|
||||
}
|
||||
|
||||
window.__initPostContent = function (selector) {
|
||||
var root = document.querySelector(selector);
|
||||
if (!root) return;
|
||||
initCopyButtons(root);
|
||||
initImageZoom(root);
|
||||
};
|
||||
})();
|
||||
|
||||
@ -16,6 +16,10 @@
|
||||
//! - 深色主题:`.dark {base} { ... }` 与 `.dark {base} .kw { ... }`;
|
||||
//! - 其中 `{base}` 为 `.md-content pre code`,保证只影响正文代码块;
|
||||
//! - `.code` 是 syntect 的根作用域特殊类,只映射到 base 容器本身。
|
||||
//!
|
||||
//! 背景色策略:syntect 输出的 `background-color` 会被剥离,代码块背景统一由
|
||||
//! `input.css` 中的 `--color-paper-code-block` 变量提供(亮色浅、暗色深),
|
||||
//! 避免 `.code` span 与外层 `pre` 双层背景叠加导致的边缘色差。
|
||||
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
|
||||
@ -100,6 +104,38 @@ fn minify_css(css: &str) -> String {
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
/// 从 CSS 规则体中移除 `background-color` 声明。
|
||||
///
|
||||
/// syntect 会为根作用域 `.code` 注入主题底色(如 Latte `#eff1f5`),这与
|
||||
/// 外层 `pre` 的 `--color-paper-code-block` 背景叠加会形成色差。这里只保留
|
||||
/// 前景色相关声明,让背景由单一来源控制。
|
||||
fn strip_background_color(body: &str) -> String {
|
||||
body.split(';')
|
||||
.filter(|decl| {
|
||||
let trimmed = decl.trim();
|
||||
!trimmed.is_empty()
|
||||
&& !trimmed
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("background-color")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
}
|
||||
|
||||
/// 剥离 CSS 字符串开头的 `/* ... */` 注释块(含其后的空白)。
|
||||
///
|
||||
/// syntect 输出的第一条规则选择器前会带主题头注释,这里在 `.code` 特判前
|
||||
/// 先清掉它,使根作用域类能被正确识别并映射到 base 容器本身。
|
||||
fn strip_leading_comment(s: &str) -> &str {
|
||||
let trimmed = s.trim_start();
|
||||
if trimmed.starts_with("/*") {
|
||||
if let Some(end) = trimmed.find("*/") {
|
||||
return trimmed[end + 2..].trim_start();
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// 将 syntect 生成的 CSS 选择器包装到指定的 base 选择器下,并可附加前缀。
|
||||
///
|
||||
/// syntect 原始输出形如:
|
||||
@ -133,6 +169,10 @@ fn rewrite_rules(css: &str, base: &str, prefix: &str) -> String {
|
||||
pos += open + 1;
|
||||
continue;
|
||||
}
|
||||
// syntect 会把主题头注释(/* theme ... */)粘在第一条规则的选择器前。
|
||||
// 注释虽在后续 minify 阶段会去除,但此处的 .code 特判发生在 minify 之前,
|
||||
// 所以这里先剥掉前导注释块,避免根作用域类匹配失败。
|
||||
let selector = strip_leading_comment(selector);
|
||||
|
||||
// 查找配对的右大括号,处理可能的嵌套 {}
|
||||
let after_open = pos + open + 1;
|
||||
@ -154,6 +194,13 @@ fn rewrite_rules(css: &str, base: &str, prefix: &str) -> String {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 剥离 syntect 注入的 background-color,代码块背景改由
|
||||
// `--color-paper-code-block` 变量统一提供(见 input.css)。
|
||||
let body = strip_background_color(body);
|
||||
if body.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// .code 是 syntect 用于根作用域的特殊类,直接映射到 base 容器
|
||||
if selector == ".code" {
|
||||
out.push_str(&format!("{}{} {{\n{}\n}}\n\n", prefix, base, body));
|
||||
|
||||
@ -1,155 +0,0 @@
|
||||
//! 图片查看器组件
|
||||
//!
|
||||
//! 提供缩略图展示与点击放大后的全屏灯箱(lightbox)查看,
|
||||
//! 支持自定义缩略图参数、alt 文本与懒加载。
|
||||
//! 灯箱支持键盘操作:Escape 关闭、Tab 在关闭按钮与图片间循环。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// 图片查看器组件。
|
||||
///
|
||||
/// Props:
|
||||
/// - `src`:原图 URL
|
||||
/// - `thumb_params`:缩略图 URL 参数,默认 `"?w=800"`
|
||||
/// - `alt`:图片替代文本,默认 `"图片"`
|
||||
/// - `lazy_load`:是否启用懒加载,默认 `false`
|
||||
///
|
||||
/// 关键事件:
|
||||
/// - 点击缩略图:打开全屏灯箱
|
||||
/// - 点击遮罩或关闭按钮:关闭灯箱
|
||||
/// - 点击灯箱内容区:阻止事件冒泡,避免误关闭
|
||||
/// - 键盘 Escape:关闭灯箱
|
||||
#[component]
|
||||
pub fn ImageViewer(
|
||||
src: String,
|
||||
#[props(default = "?w=800".to_string())] thumb_params: String,
|
||||
#[props(default = "?w=20".to_string())] placeholder_params: String,
|
||||
#[props(default = "图片".to_string())] alt: String,
|
||||
#[props(default = false)] lazy_load: bool,
|
||||
) -> Element {
|
||||
let mut is_open = use_signal(|| false);
|
||||
|
||||
// 打开灯箱时聚焦关闭按钮,并监听 Escape 键关闭。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
|
||||
use_effect(move || {
|
||||
if !is_open() {
|
||||
return;
|
||||
}
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// 下一帧再聚焦,确保 DOM 已渲染。
|
||||
let _ = js_sys::eval("new Promise(r => requestAnimationFrame(r))").unwrap();
|
||||
if let Some(btn) = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.query_selector(".image-viewer-close").ok())
|
||||
.flatten()
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok())
|
||||
{
|
||||
let _ = btn.focus();
|
||||
}
|
||||
});
|
||||
|
||||
let closure = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
|
||||
if e.key() == "Escape" {
|
||||
is_open.set(false);
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
|
||||
}
|
||||
|
||||
closure.forget();
|
||||
});
|
||||
}
|
||||
|
||||
// 计算 aspect-ratio:SSR 时读图片真实尺寸。WASM 端不读(--ar 已在 SSR 写入 HTML)。
|
||||
// 非 /uploads/ 的外链图或读不到尺寸时不设 --ar。
|
||||
let ar_style = {
|
||||
let mut s = String::new();
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
if let Some(rel) = src
|
||||
.strip_prefix("/uploads/")
|
||||
.map(|p| p.split('?').next().unwrap_or(p))
|
||||
{
|
||||
if let Some((w, h)) = crate::api::image::get_image_dimensions(rel) {
|
||||
// 注意:CSS aspect-ratio 用斜杠分隔(width / height),不是冒号
|
||||
s = format!("--ar:{} / {};", w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
|
||||
// 拼接占位图 URL 和高清图 URL
|
||||
let placeholder_src = if src.contains('?') {
|
||||
format!(
|
||||
"{}&{}",
|
||||
src.split('?').next().unwrap_or(&src),
|
||||
placeholder_params.trim_start_matches('?')
|
||||
)
|
||||
} else {
|
||||
format!("{}{}", src, placeholder_params)
|
||||
};
|
||||
let full_src = if src.contains('?') {
|
||||
format!(
|
||||
"{}&{}",
|
||||
src.split('?').next().unwrap_or(&src),
|
||||
thumb_params.trim_start_matches('?')
|
||||
)
|
||||
} else {
|
||||
format!("{}{}", src, thumb_params)
|
||||
};
|
||||
|
||||
rsx! {
|
||||
// blur-up 双层:底层占位图 + 上层高清图(data-src 由前端 JS 懒加载)
|
||||
span {
|
||||
class: "blur-img",
|
||||
style: "{ar_style}",
|
||||
onclick: move |_| is_open.set(true),
|
||||
img {
|
||||
class: "blur-img-placeholder",
|
||||
src: "{placeholder_src}",
|
||||
alt: "{alt}",
|
||||
loading: if lazy_load { "lazy" } else { "eager" },
|
||||
}
|
||||
img {
|
||||
class: "blur-img-full",
|
||||
"data-src": "{full_src}",
|
||||
alt: "{alt}",
|
||||
}
|
||||
}
|
||||
|
||||
// 全屏灯箱
|
||||
if is_open() {
|
||||
div {
|
||||
class: "image-viewer-overlay",
|
||||
role: "dialog",
|
||||
aria_modal: "true",
|
||||
aria_label: "图片预览",
|
||||
onclick: move |_| is_open.set(false),
|
||||
div {
|
||||
class: "image-viewer-content",
|
||||
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
|
||||
img {
|
||||
class: "image-viewer-img",
|
||||
src: "{src}",
|
||||
alt: "{alt}",
|
||||
tabindex: 0,
|
||||
}
|
||||
button {
|
||||
class: "image-viewer-close",
|
||||
r#type: "button",
|
||||
aria_label: "关闭图片预览",
|
||||
onclick: move |_| is_open.set(false),
|
||||
"✕"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
//! 提供 Dioxus UI 组件,供前端页面(`src/pages/`)使用。
|
||||
//! 包括布局(`frontend_layout`、`admin_layout`)、导航(`header`、`nav`、`footer`)、
|
||||
//! 文章展示(`post`、`post_card`)、评论(`comments`)、骨架屏(`skeletons`)、
|
||||
//! 表单控件(`forms`)以及图片查看器(`image_viewer`)等共享组件。
|
||||
//! 表单控件(`forms`)等共享组件。
|
||||
|
||||
/// 后台布局组件。
|
||||
pub mod admin_layout;
|
||||
@ -19,8 +19,6 @@ pub mod forms;
|
||||
pub mod frontend_layout;
|
||||
/// 顶部导航栏组件。
|
||||
pub mod header;
|
||||
/// 图片查看器组件。
|
||||
pub mod image_viewer;
|
||||
/// 导航组件。
|
||||
pub mod nav;
|
||||
/// 文章详情组件。
|
||||
|
||||
@ -10,14 +10,17 @@ use dioxus::prelude::*;
|
||||
/// - `content_html`:服务端渲染的文章 HTML 字符串
|
||||
///
|
||||
/// 关键行为:
|
||||
/// - 在 `target_arch = "wasm32"` 环境下执行 `post-content.js` 并调用初始化函数,
|
||||
/// 用于处理代码块、图片点击等前端交互
|
||||
/// - 在 `target_arch = "wasm32"` 环境下执行 `post-content.js`(代码块复制)与
|
||||
/// `lightbox.js`(图片灯箱 + 懒加载),并调用各自的初始化函数。
|
||||
#[component]
|
||||
pub fn PostContent(content_html: String) -> Element {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use_effect(move || {
|
||||
let _ = js_sys::eval(include_str!("../../../public/js/post-content.js"));
|
||||
let _ = js_sys::eval(include_str!("../../../public/js/lightbox.js"));
|
||||
let _ = js_sys::eval("window.__initPostContent('.post-content')");
|
||||
// 正文图组成图集;封面(.entry-cover,带 lightbox-single class)单张模式。
|
||||
let _ = js_sys::eval("window.__initLightbox(['.post-content', '.entry-cover'])");
|
||||
});
|
||||
|
||||
rsx! {
|
||||
|
||||
@ -1,26 +1,66 @@
|
||||
//! 文章封面组件
|
||||
//!
|
||||
//! 在文章详情页渲染封面大图,使用图片查看器支持点击放大。
|
||||
//! 在文章详情页渲染封面大图,使用 blur-up 双层结构与 lightbox.js 灯箱。
|
||||
//! 封面为单张(data-single),不参与正文图集切换。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::image_viewer::ImageViewer;
|
||||
|
||||
/// 文章封面组件。
|
||||
///
|
||||
/// Props:
|
||||
/// - `src`:封面原图 URL
|
||||
///
|
||||
/// 使用 1200px 宽度的缩略图作为默认封面展示,点击可放大查看。
|
||||
/// 渲染 `.blur-img` 结构,与正文图片一致;`data-single="true"` 标记为单张,
|
||||
/// 由 `lightbox.js` 接管点击放大(原地缩放飞出 + 原图展示)。
|
||||
/// 服务端读取真实尺寸写入 `--ar`,确保占位期间维持正确宽高比。
|
||||
#[component]
|
||||
pub fn PostCover(src: String) -> Element {
|
||||
// SSR 时读真实尺寸算 --ar;WASM 端不读(HTML 已在 SSR 写入)。
|
||||
let ar_style = {
|
||||
#[cfg_attr(not(feature = "server"), allow(unused_mut))]
|
||||
let mut s = String::new();
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
if let Some(rel) = src
|
||||
.strip_prefix("/uploads/")
|
||||
.map(|p| p.split('?').next().unwrap_or(p))
|
||||
{
|
||||
if let Some((w, h)) = crate::api::image::get_image_dimensions(rel) {
|
||||
// CSS aspect-ratio 用斜杠分隔(width / height)
|
||||
s = format!("--ar:{} / {};", w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
|
||||
// 占位图 ?w=20,展示图 ?w=1200;灯箱原图由 lightbox.js 去 query 得到。
|
||||
let placeholder_src = if src.contains('?') {
|
||||
format!("{}&w=20", src.split('?').next().unwrap_or(&src))
|
||||
} else {
|
||||
format!("{}?w=20", src)
|
||||
};
|
||||
let full_src = if src.contains('?') {
|
||||
format!("{}&w=1200", src.split('?').next().unwrap_or(&src))
|
||||
} else {
|
||||
format!("{}?w=1200", src)
|
||||
};
|
||||
|
||||
rsx! {
|
||||
figure { class: "entry-cover",
|
||||
ImageViewer {
|
||||
src: src.clone(),
|
||||
thumb_params: "?w=1200",
|
||||
alt: "封面图片".to_string(),
|
||||
lazy_load: false,
|
||||
span {
|
||||
class: "blur-img entry-cover-blur lightbox-single",
|
||||
style: "{ar_style}",
|
||||
img {
|
||||
class: "blur-img-placeholder",
|
||||
src: "{placeholder_src}",
|
||||
alt: "封面图片",
|
||||
}
|
||||
img {
|
||||
class: "blur-img-full",
|
||||
"data-src": "{full_src}",
|
||||
alt: "封面图片",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus::router::components::Link;
|
||||
|
||||
use crate::components::image_viewer::ImageViewer;
|
||||
use crate::models::post::PostListItem;
|
||||
use crate::router::Route;
|
||||
|
||||
@ -15,62 +14,77 @@ use crate::router::Route;
|
||||
/// - `post`:文章数据模型
|
||||
///
|
||||
/// 展示内容包括:
|
||||
/// - 封面图(如有,使用 400x300 缩略图)
|
||||
/// - 封面图(如有,使用 400x300 缩略图,不启用灯箱)
|
||||
/// - 文章标题
|
||||
/// - 摘要(最多两行)
|
||||
/// - 发布日期与标签
|
||||
///
|
||||
/// 关键事件:
|
||||
/// - 点击标签时阻止事件冒泡,避免触发整卡跳转
|
||||
/// 交互模型(采用覆盖层链接,避免 `<a>` 嵌套 `<a>` 的非法 HTML):
|
||||
/// - 整张卡片可点击跳转到文章详情:通过末尾一个绝对定位、铺满卡片的覆盖层 `Link` 实现。
|
||||
/// - 标签是独立的 `Link`,通过 `relative z-10` 叠在覆盖层之上,并 `stop_propagation`,
|
||||
/// 点击标签进入标签详情页而不触发卡片跳转。
|
||||
/// - 封面用裸 `.blur-img`(纯展示,无灯箱),点击走卡片跳转,避免交互歧义。
|
||||
#[component]
|
||||
pub fn PostCard(post: PostListItem) -> Element {
|
||||
let post_slug = post.slug.clone();
|
||||
let date_str = post.formatted_date();
|
||||
let cover_src = post.cover_image.clone().unwrap_or_default();
|
||||
let has_cover = post.cover_image.is_some();
|
||||
|
||||
rsx! {
|
||||
article {
|
||||
class: "relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200",
|
||||
Link {
|
||||
class: "block group",
|
||||
to: Route::PostDetail { slug: post_slug },
|
||||
if has_cover {
|
||||
class: "group relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200",
|
||||
if has_cover {
|
||||
div {
|
||||
class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg",
|
||||
div {
|
||||
class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg",
|
||||
ImageViewer {
|
||||
src: post.cover_image.clone().unwrap_or_default(),
|
||||
thumb_params: "?thumb=400x300",
|
||||
alt: post.title.clone(),
|
||||
lazy_load: true,
|
||||
class: "blur-img post-card-cover-blur",
|
||||
img {
|
||||
class: "blur-img-placeholder",
|
||||
src: "{cover_src}?w=20",
|
||||
alt: "{post.title}",
|
||||
loading: "lazy",
|
||||
}
|
||||
img {
|
||||
class: "blur-img-full is-loaded",
|
||||
src: "{cover_src}?thumb=400x300",
|
||||
alt: "{post.title}",
|
||||
}
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200",
|
||||
"{post.title}"
|
||||
}
|
||||
div {
|
||||
class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2",
|
||||
"{post.summary.as_deref().unwrap_or_default()}"
|
||||
}
|
||||
div {
|
||||
class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary",
|
||||
span { "{date_str}" }
|
||||
if !post.tags.is_empty() {
|
||||
span { "·" }
|
||||
for tag in post.tags.clone().into_iter() {
|
||||
span {
|
||||
Link {
|
||||
class: "hover:text-paper-accent transition-colors duration-200",
|
||||
to: Route::TagDetail { tag: tag.clone() },
|
||||
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
|
||||
"{tag}"
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200",
|
||||
"{post.title}"
|
||||
}
|
||||
div {
|
||||
class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2",
|
||||
"{post.summary.as_deref().unwrap_or_default()}"
|
||||
}
|
||||
div {
|
||||
class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary",
|
||||
span { "{date_str}" }
|
||||
if !post.tags.is_empty() {
|
||||
span { "·" }
|
||||
for tag in post.tags.clone().into_iter() {
|
||||
span {
|
||||
// 标签叠在覆盖链接之上,点击进入标签详情页而非文章详情。
|
||||
Link {
|
||||
class: "relative z-10 hover:text-paper-accent transition-colors duration-200",
|
||||
to: Route::TagDetail { tag: tag.clone() },
|
||||
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
|
||||
"{tag}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 覆盖层链接:铺满卡片,承担整卡跳转。z-0 位于标签 (z-10) 之下。
|
||||
Link {
|
||||
class: "absolute inset-0 z-0",
|
||||
aria_label: "post link to {post.title}",
|
||||
to: Route::PostDetail { slug: post_slug },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,13 +9,14 @@ use crate::components::skeletons::post_card_skeleton::PostCardSkeleton;
|
||||
|
||||
/// 首页骨架屏组件。
|
||||
///
|
||||
/// 显示 5 个文章卡片骨架与分页按钮占位。
|
||||
/// 显示与 `POSTS_PER_PAGE` 等量的文章卡片骨架与分页按钮占位,
|
||||
/// 使骨架屏与加载完成后的实际列表长度一致,避免内容跳变。
|
||||
#[component]
|
||||
pub fn HomeSkeleton() -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
// 5 个文章卡片骨架
|
||||
for _ in 0..5 {
|
||||
// 10 个文章卡片骨架,对齐首页 POSTS_PER_PAGE。
|
||||
for _ in 0..10 {
|
||||
PostCardSkeleton {}
|
||||
}
|
||||
// 分页按钮占位
|
||||
|
||||
@ -28,7 +28,7 @@ use wasm_bindgen::closure::Closure;
|
||||
///
|
||||
/// 内部委托给 `write_editor`,以 `None` 表示新建模式。
|
||||
#[component]
|
||||
#[allow(unused_mut, unused_variables)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
pub fn Write() -> Element {
|
||||
write_editor(None)
|
||||
}
|
||||
@ -37,7 +37,7 @@ pub fn Write() -> Element {
|
||||
///
|
||||
/// `id` 为要编辑的文章 ID,内部委托给 `write_editor` 加载现有数据。
|
||||
#[component]
|
||||
#[allow(unused_mut, unused_variables)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
pub fn WriteEdit(id: i32) -> Element {
|
||||
write_editor(Some(id))
|
||||
}
|
||||
@ -50,7 +50,7 @@ pub fn WriteEdit(id: i32) -> Element {
|
||||
/// - 编辑模式下将 Markdown 内容回填到编辑器;
|
||||
/// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post;
|
||||
/// - 组件卸载时销毁 Tiptap 实例(EditorHandle::drop 自动 destroy + 释放 closure)。
|
||||
#[allow(unused_mut, unused_variables)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
|
||||
fn write_editor(post_id: Option<i32>) -> Element {
|
||||
let is_edit = post_id.is_some();
|
||||
|
||||
@ -66,7 +66,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
let mut loading = use_signal(|| true);
|
||||
let mut saving = use_signal(|| false);
|
||||
let mut error = use_signal(|| None::<String>);
|
||||
let mut success = use_signal(|| false);
|
||||
let mut editor_content_set = use_signal(|| false);
|
||||
let mut has_backfilled = use_signal(|| false);
|
||||
let mut load_error = use_signal(|| None::<String>);
|
||||
@ -79,16 +78,11 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
let mut editor: Signal<Option<EditorHandle>> = use_signal(|| None);
|
||||
// WASM 前端:编辑器就绪标志(onReady 回调驱动,替代 __tiptap_ready 轮询)。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let mut ready = use_signal(|| false);
|
||||
let ready = use_signal(|| false);
|
||||
|
||||
// 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭)
|
||||
let mut uploads_in_flight = use_signal(UploadsInFlight::default);
|
||||
let mut upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
|
||||
// 已展示过错误的上传 id(去重 + 重试后再失败时原地更新)。
|
||||
// 用 signal 而非局部变量:closure 的 FnMut 闭包要求可重复调用,
|
||||
// signal 是 Copy 可被多次 move。
|
||||
let mut seen_error_ids: Signal<std::collections::HashSet<String>> =
|
||||
use_signal(std::collections::HashSet::new);
|
||||
let uploads_in_flight = use_signal(UploadsInFlight::default);
|
||||
let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
|
||||
|
||||
// 编辑模式:文章数据加载完成后,将字段回填到表单信号。
|
||||
use_effect(move || {
|
||||
@ -161,11 +155,10 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
});
|
||||
let on_image_upload = crate::tiptap_bridge::make_upload_closure();
|
||||
let on_upload_event = Closure::new({
|
||||
let mut uploads_in_flight = uploads_in_flight;
|
||||
let mut upload_errors = upload_errors;
|
||||
let mut seen_error_ids = seen_error_ids;
|
||||
let uploads_in_flight = uploads_in_flight;
|
||||
let upload_errors = upload_errors;
|
||||
move |ev: crate::tiptap_bridge::UploadEventJs| {
|
||||
consume_upload_event(&ev, &mut uploads_in_flight, &mut upload_errors, &mut seen_error_ids);
|
||||
consume_upload_event(&ev, uploads_in_flight, upload_errors);
|
||||
}
|
||||
});
|
||||
|
||||
@ -240,13 +233,11 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
|
||||
if title().trim().is_empty() {
|
||||
error.set(Some("标题不能为空".to_string()));
|
||||
#[allow(clippy::needless_return)]
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅在 WASM 前端读取编辑器内容并发起保存请求。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if !title().trim().is_empty() {
|
||||
// 通过 EditorHandle 实例读取 Markdown;句柄未就绪时退回 content signal。
|
||||
let md = if let Some(handle) = &*editor.read() {
|
||||
handle.instance().get_markdown()
|
||||
@ -268,8 +259,9 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
}
|
||||
|
||||
// 将逗号分隔的标签字符串转换为列表。
|
||||
// 同时支持半角/全角逗号与分号,避免中文输入法下的全角标点被误并入单个标签。
|
||||
let tags_list: Vec<String> = tags()
|
||||
.split(',')
|
||||
.split(|c| matches!(c, ',' | ',' | ';' | ';'))
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
@ -312,7 +304,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
{
|
||||
Ok(CreatePostResponse { success: true, .. }) => {
|
||||
saving.set(false);
|
||||
success.set(true);
|
||||
let _ = dioxus::router::navigator().push(Route::Posts {});
|
||||
}
|
||||
Ok(CreatePostResponse {
|
||||
@ -345,8 +336,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
{
|
||||
Ok(CreatePostResponse { success: true, .. }) => {
|
||||
saving.set(false);
|
||||
success.set(true);
|
||||
let _ = dioxus::router::navigator().push(Route::Admin {});
|
||||
let _ = dioxus::router::navigator().push(Route::Posts {});
|
||||
}
|
||||
Ok(CreatePostResponse {
|
||||
success: false,
|
||||
@ -364,6 +354,12 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 非 WASM(SSR hydration 前等场景):明确提示无法保存,而非静默无反应。
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !title().trim().is_empty() {
|
||||
error.set(Some("此页面需要 JavaScript 才能保存".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let save_button_text = if saving() {
|
||||
@ -374,6 +370,12 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
"保存"
|
||||
};
|
||||
|
||||
// 元信息表单复用的样式:label 与 input 各一份,避免三处重复粘贴。
|
||||
const META_LABEL_CLASS: &str =
|
||||
"block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2";
|
||||
const META_INPUT_CLASS: &str =
|
||||
"w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5";
|
||||
|
||||
rsx! {
|
||||
div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden",
|
||||
if loading() {
|
||||
@ -406,33 +408,33 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
// 元数据行 - 紧凑精致
|
||||
div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm",
|
||||
div { class: "flex-1 min-w-[140px]",
|
||||
label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2",
|
||||
label { class: "{META_LABEL_CLASS}",
|
||||
"Slug"
|
||||
}
|
||||
input {
|
||||
class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5",
|
||||
class: "{META_INPUT_CLASS}",
|
||||
placeholder: "自动生成",
|
||||
value: "{slug}",
|
||||
oninput: move |evt| slug.set(evt.value()),
|
||||
}
|
||||
}
|
||||
div { class: "flex-1 min-w-[140px]",
|
||||
label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2",
|
||||
label { class: "{META_LABEL_CLASS}",
|
||||
"标签"
|
||||
}
|
||||
input {
|
||||
class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5",
|
||||
class: "{META_INPUT_CLASS}",
|
||||
placeholder: "逗号分隔",
|
||||
value: "{tags}",
|
||||
oninput: move |evt| tags.set(evt.value()),
|
||||
}
|
||||
}
|
||||
div { class: "flex-1 min-w-[140px]",
|
||||
label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2",
|
||||
label { class: "{META_LABEL_CLASS}",
|
||||
"封面图"
|
||||
}
|
||||
input {
|
||||
class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5",
|
||||
class: "{META_INPUT_CLASS}",
|
||||
placeholder: "URL(可选)",
|
||||
value: "{cover_image}",
|
||||
oninput: move |evt| cover_image.set(evt.value()),
|
||||
@ -462,12 +464,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
if success() {
|
||||
div { class: "flex-shrink-0 px-4 py-2 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-xl text-sm border border-green-100 dark:border-green-900/30 mb-2",
|
||||
"保存成功"
|
||||
}
|
||||
}
|
||||
|
||||
// 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿)
|
||||
for err in upload_errors().clone() {
|
||||
div { class: "flex-shrink-0 flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2",
|
||||
|
||||
@ -63,33 +63,38 @@ fn HomePosts(current_page: i32) -> Element {
|
||||
for post in posts.iter() {
|
||||
PostCard { post: post.clone() }
|
||||
}
|
||||
// 如果当前页没有任何文章,显示空状态提示。
|
||||
if posts.is_empty() {
|
||||
// total == 0 表示站点确实无文章:显示空状态,且不渲染分页。
|
||||
// 注意:total > 0 但 posts 为空(如越界页码 /page/9999)也不显示空状态,
|
||||
// 避免误导用户以为站点无内容;此时仅靠下方的分页导航引导回有效页。
|
||||
if total == 0 {
|
||||
div { class: "text-center text-paper-secondary py-20",
|
||||
"暂无文章"
|
||||
}
|
||||
}
|
||||
// 在列表底部渲染分页导航。
|
||||
// 仅在有文章时渲染分页导航,避免越界页码下出现孤立的空分页。
|
||||
// frontend variant 不渲染页码计数,unit 不显示(仅满足必填 prop)。
|
||||
Pagination {
|
||||
variant: "frontend",
|
||||
current_page,
|
||||
total,
|
||||
per_page: POSTS_PER_PAGE,
|
||||
prev_route: if current_page - 1 <= 1 {
|
||||
Route::Home {}
|
||||
} else {
|
||||
Route::HomePage { page: current_page - 1 }
|
||||
},
|
||||
next_route: Route::HomePage { page: current_page + 1 },
|
||||
unit: "篇",
|
||||
if total > 0 {
|
||||
Pagination {
|
||||
variant: "frontend",
|
||||
current_page,
|
||||
total,
|
||||
per_page: POSTS_PER_PAGE,
|
||||
prev_route: if current_page - 1 <= 1 {
|
||||
Route::Home {}
|
||||
} else {
|
||||
Route::HomePage { page: current_page - 1 }
|
||||
},
|
||||
next_route: Route::HomePage { page: current_page + 1 },
|
||||
unit: "篇",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
// 不透传内部错误细节,统一展示通用文案(与标签页等其它页面一致)。
|
||||
Some(Err(_)) => {
|
||||
rsx! {
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"加载失败: {e}"
|
||||
"加载失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,9 +78,13 @@ pub fn Search() -> Element {
|
||||
PostCard { post: post.clone() }
|
||||
}
|
||||
}
|
||||
} else if let Some(Err(e)) = search_res() {
|
||||
} else if search_res()
|
||||
.as_ref()
|
||||
.map(|r| r.is_err())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
div { class: "text-center text-red-500 dark:text-red-400 py-20",
|
||||
"搜索失败: {e}"
|
||||
"搜索失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,8 +29,8 @@ pub struct UploadErrorEntry {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod wasm {
|
||||
use super::{UploadErrorEntry, UploadsInFlight};
|
||||
// WritableExt 提供 .write();Writable 提供 .set()。Signal 方法需 trait 在作用域内。
|
||||
use dioxus::prelude::{Readable, Writable, WritableExt};
|
||||
// WritableExt 提供 .write()(Signal 在 Copy 语义下不需要 mut 绑定)。
|
||||
use dioxus::prelude::WritableExt;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
@ -144,12 +144,9 @@ pub mod wasm {
|
||||
|
||||
/// 持有编辑器实例 + 其全部 closure,统一生命周期。
|
||||
///
|
||||
/// drop 时先 destroy JS 实例(释放 ProseMirror 资源),再 drop closure
|
||||
/// (释放 wasm-bindgen 回调表)。比 `Closure::forget`(永久泄漏)干净——
|
||||
/// closure 严格随编辑器实例同生共死。
|
||||
///
|
||||
/// 字段顺序:`instance` 在前仅表示逻辑主从;Drop::drop 里显式调 destroy,
|
||||
/// 之后 closure 字段按声明逆序自动 drop,无顺序敏感问题。
|
||||
/// drop 时先 destroy JS 实例(释放 ProseMirror 资源),随后 closure 字段
|
||||
/// 按声明逆序自动 drop(释放 wasm-bindgen 回调表)。比 `Closure::forget`
|
||||
/// (永久泄漏)干净——closure 严格随编辑器实例同生共死。
|
||||
pub struct EditorHandle {
|
||||
instance: EditorInstance,
|
||||
_on_update: Closure<dyn FnMut(String)>,
|
||||
@ -184,7 +181,6 @@ pub mod wasm {
|
||||
impl Drop for EditorHandle {
|
||||
fn drop(&mut self) {
|
||||
self.instance.destroy();
|
||||
// closure 字段随后自动 drop(逆序:on_upload_event → on_ready → on_image_upload → on_update)
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,35 +190,33 @@ pub mod wasm {
|
||||
///
|
||||
/// 逻辑与旧轮询 body 一致:
|
||||
/// - error:新 id 追加提示,已存在 id 原地更新消息(重试后再失败)
|
||||
/// - success/removed:移除对应提示 + 清 seen
|
||||
/// - success/removed:移除对应提示
|
||||
/// - counts:直接从事件读(JS 已遍历文档算好)
|
||||
///
|
||||
/// 去重以 `upload_errors` Vec 自身为唯一数据源(用 iter().any 判重),
|
||||
/// 不再额外维护 seen_error_ids,避免两份状态需手动同步。
|
||||
pub fn consume_upload_event(
|
||||
ev: &UploadEventJs,
|
||||
uploads_in_flight: &mut dioxus::prelude::Signal<UploadsInFlight>,
|
||||
upload_errors: &mut dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
|
||||
seen_error_ids: &mut dioxus::prelude::Signal<std::collections::HashSet<String>>,
|
||||
mut uploads_in_flight: dioxus::prelude::Signal<UploadsInFlight>,
|
||||
mut upload_errors: dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
|
||||
) {
|
||||
let id = ev.upload_id();
|
||||
match ev.kind().as_str() {
|
||||
"error" => {
|
||||
let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
|
||||
if seen_error_ids.write().insert(id.clone()) {
|
||||
// 新失败:追加提示
|
||||
upload_errors.write().push(UploadErrorEntry {
|
||||
// 已存在同 id(重试后再失败):原地更新消息;否则追加。
|
||||
let mut errors = upload_errors.write();
|
||||
if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
|
||||
entry.message = msg;
|
||||
} else {
|
||||
errors.push(UploadErrorEntry {
|
||||
id: id.clone(),
|
||||
file_name: ev.file_name(),
|
||||
message: msg,
|
||||
});
|
||||
} else {
|
||||
// 已存在的 id(重试后再失败):原地更新消息
|
||||
let mut errors = upload_errors.write();
|
||||
if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
|
||||
entry.message = msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
"success" | "removed" => {
|
||||
seen_error_ids.write().remove(&id);
|
||||
upload_errors.write().retain(|e| e.id != id);
|
||||
}
|
||||
_ => {}
|
||||
@ -245,27 +239,31 @@ pub mod wasm {
|
||||
/// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error))
|
||||
///
|
||||
/// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。
|
||||
/// 构造阶段的错误(FormData/Request 等)以 rejected Promise 返回,
|
||||
/// 而非 panic —— 单张坏文件不应导致整个编辑器崩溃。
|
||||
pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> {
|
||||
Closure::new(move |file: web_sys::File| -> js_sys::Promise {
|
||||
// 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐
|
||||
let form = web_sys::FormData::new().expect("FormData::new failed");
|
||||
form.append_with_blob("image", &file)
|
||||
.expect("FormData.append failed");
|
||||
|
||||
// 构造 POST 请求,credentials same-origin 携带 session cookie
|
||||
let mut init = web_sys::RequestInit::new();
|
||||
init.method("POST");
|
||||
init.body(Some(&form));
|
||||
init.credentials(web_sys::RequestCredentials::SameOrigin);
|
||||
|
||||
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
|
||||
.expect("Request::new failed");
|
||||
|
||||
let window = web_sys::window().expect("no window");
|
||||
let promise = window.fetch_with_request(&request);
|
||||
|
||||
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
|
||||
wasm_bindgen_futures::future_to_promise(async move {
|
||||
// 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐
|
||||
let form = web_sys::FormData::new()
|
||||
.map_err(|_| js_sys::Error::new("无法构造上传表单"))?;
|
||||
form.append_with_blob("image", &file)
|
||||
.map_err(|_| js_sys::Error::new("无法附加文件"))?;
|
||||
|
||||
// 构造 POST 请求,credentials same-origin 携带 session cookie
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method("POST");
|
||||
// set_body 接收 &JsValue(非 Option);FormData: AsRef<JsValue>。
|
||||
init.set_body(form.as_ref());
|
||||
init.set_credentials(web_sys::RequestCredentials::SameOrigin);
|
||||
|
||||
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
|
||||
.map_err(|_| js_sys::Error::new("无法构造上传请求"))?;
|
||||
|
||||
let window = web_sys::window().expect("no window");
|
||||
let promise = window.fetch_with_request(&request);
|
||||
|
||||
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
|
||||
let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||
let resp: web_sys::Response = resp_val.dyn_into()?;
|
||||
|
||||
@ -303,6 +301,6 @@ pub mod wasm {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use wasm::{
|
||||
consume_upload_event, make_upload_closure, EditorHandle, EditorInstance, EditorOptions,
|
||||
TiptapEditorModule, UploadEventJs, get_module,
|
||||
consume_upload_event, make_upload_closure, EditorHandle, EditorOptions, UploadEventJs,
|
||||
get_module,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user