feat(frontend): Markdown 支持 GFM、代码高亮与图片灯箱

统一 SSR/客户端渲染路径,评论与预览共用增强组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:41:25 +08:00
parent f3145b24ff
commit 2056d6a3f9
14 changed files with 1458 additions and 31 deletions

View File

@@ -0,0 +1,122 @@
"use client";
import { useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
export type LightboxItem = {
src: string;
alt?: string;
};
type ImageLightboxProps = {
items: LightboxItem[];
index: number;
onClose: () => void;
onIndexChange: (index: number) => void;
};
/** 全屏图箱:Esc 关闭,多图时左右切换 */
export default function ImageLightbox({
items,
index,
onClose,
onIndexChange,
}: ImageLightboxProps) {
const total = items.length;
const current = items[index];
const hasNav = total > 1;
const goPrev = useCallback(() => {
if (!hasNav) return;
onIndexChange((index - 1 + total) % total);
}, [hasNav, index, total, onIndexChange]);
const goNext = useCallback(() => {
if (!hasNav) return;
onIndexChange((index + 1) % total);
}, [hasNav, index, total, onIndexChange]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose, goPrev, goNext]);
if (!current || typeof document === "undefined") return null;
return createPortal(
<div
className="md-lightbox"
role="dialog"
aria-modal="true"
aria-label="图片预览"
onClick={onClose}
>
<button
type="button"
className="md-lightbox-close"
aria-label="关闭"
onClick={onClose}
>
<X size={22} />
</button>
{hasNav && (
<>
<button
type="button"
className="md-lightbox-nav md-lightbox-prev"
aria-label="上一张"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
>
<ChevronLeft size={28} />
</button>
<button
type="button"
className="md-lightbox-nav md-lightbox-next"
aria-label="下一张"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
>
<ChevronRight size={28} />
</button>
<div className="md-lightbox-counter" onClick={(e) => e.stopPropagation()}>
{index + 1} / {total}
</div>
</>
)}
<figure
className="md-lightbox-figure"
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={current.alt || ""}
className="md-lightbox-img"
draggable={false}
/>
{current.alt ? (
<figcaption className="md-lightbox-caption">{current.alt}</figcaption>
) : null}
</figure>
</div>,
document.body
);
}