Files
jiang13-bbs/frontend/components/markdown/MdHideBlock.tsx
freefire c44b0efa7d feat(hide): 密码可见隐藏块,发帖双栏预览与门禁体验修复
增加密码解锁与游客签名 cookie;发帖页对齐 1440 并默认双栏预览;修复 locked 解析、按钮对比度与回复聚焦。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 05:46:42 +08:00

222 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { ReactNode, FormEvent } from "react";
import { Lock } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useId, useState } from "react";
import { apiUnlockPost, apiUnlockPostPassword } from "@/lib/api";
import { CSRF_COOKIE } from "@/lib/cookies";
import { toast } from "@/lib/toast";
import { hideKindLabel, type HideKind } from "@/lib/hideBlocks";
/** 若浏览器尚无 CSRF cookie,请求公开接口以触发后端签发 */
async function ensureGuestCSRF() {
if (typeof document === "undefined") return;
const escaped = CSRF_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (new RegExp(`(?:^|;\\s*)${escaped}=`).test(document.cookie)) return;
try {
await fetch("/api/me", { credentials: "include", cache: "no-store" });
} catch {
/* 忽略:后续解锁会报 CSRF 错误 */
}
}
type MdHideBlockProps = {
kind: HideKind;
points?: number;
locked?: boolean;
postId?: number;
loggedIn?: boolean;
children?: ReactNode;
};
/** 正文内嵌隐藏块:已解锁显示徽章容器;locked 显示就地门禁 */
export default function MdHideBlock({
kind,
points = 0,
locked = false,
postId,
loggedIn = false,
children,
}: MdHideBlockProps) {
const router = useRouter();
const pwdErrId = useId();
const [busy, setBusy] = useState(false);
const [pwd, setPwd] = useState("");
const [pwdError, setPwdError] = useState("");
const label = hideKindLabel(kind);
if (locked) {
const unlockPoints = async () => {
if (!postId) return;
setBusy(true);
try {
const res = await apiUnlockPost(String(postId));
if (res.post) {
toast("已解锁隐藏内容", "ok");
router.refresh();
} else {
toast(res.error || "解锁失败", "error");
}
} catch {
toast("解锁失败", "error");
} finally {
setBusy(false);
}
};
const unlockPassword = async (e: FormEvent) => {
e.preventDefault();
if (!postId) return;
const password = pwd.trim();
if (!password) {
setPwdError("请输入密码");
return;
}
setBusy(true);
setPwdError("");
try {
// 游客可能尚无 CSRF:先打一次公开 GET 让后端签发,再提交解锁
await ensureGuestCSRF();
const res = await apiUnlockPostPassword(String(postId), password);
if (res.post) {
toast("密码正确,已解锁", "ok");
setPwd("");
setPwdError("");
router.refresh();
} else {
setPwdError(res.error || "密码错误,请重试");
}
} catch {
setPwdError("验证失败,请稍后重试");
} finally {
setBusy(false);
}
};
return (
<aside
className="j13-hide-gate"
role="note"
aria-label={`${label}(未解锁)`}
>
<Lock size={22} className="j13-hide-gate-icon" aria-hidden />
<p className="j13-hide-gate-title">此段内容{label}</p>
<p className="j13-hide-gate-desc meta">
{kind === "points"
? `支付 ${points || 0} 积分后可阅读此段`
: kind === "reply"
? loggedIn
? "发表一条回复后即可阅读此段"
: "登录并发表一条回复后即可阅读此段"
: kind === "password"
? "输入正确密码后可阅读此段"
: "登录后即可阅读此段"}
</p>
<div className="j13-hide-gate-actions">
{kind === "login" && !loggedIn && postId && (
<Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
className="btn btn-primary"
>
去登录
</Link>
)}
{kind === "points" && loggedIn && postId && (
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => void unlockPoints()}
>
{busy ? "解锁中…" : `支付 ${points || 0} 积分解锁`}
</button>
)}
{kind === "points" && !loggedIn && postId && (
<Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
className="btn btn-primary"
>
登录后解锁
</Link>
)}
{kind === "reply" && postId && !loggedIn && (
<Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}#comment-input`)}`}
className="btn btn-primary"
>
登录后去回复
</Link>
)}
{kind === "reply" && loggedIn && (
<a
href="#comment-input"
className="btn btn-primary"
onClick={(e) => {
e.preventDefault();
const el = document.getElementById("comment-input");
if (!(el instanceof HTMLTextAreaElement)) {
document.getElementById("comments")?.scrollIntoView({
behavior: "smooth",
block: "start",
});
return;
}
el.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
el.focus({ preventScroll: true });
}, 280);
}}
>
去回复
</a>
)}
{kind === "password" && postId && (
<div className="j13-hide-pwd-wrap">
<form className="j13-hide-pwd-form" onSubmit={(e) => void unlockPassword(e)}>
<input
type="password"
className={`j13-hide-pwd-input${pwdError ? " is-invalid" : ""}`}
placeholder="输入密码"
value={pwd}
maxLength={64}
autoComplete="off"
disabled={busy}
aria-invalid={pwdError ? true : undefined}
aria-describedby={pwdError ? pwdErrId : undefined}
onChange={(e) => {
setPwd(e.target.value);
if (pwdError) setPwdError("");
}}
aria-label="隐藏内容密码"
/>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? "验证中…" : "查看"}
</button>
</form>
{pwdError && (
<p id={pwdErrId} className="j13-hide-pwd-error" role="alert">
{pwdError}
</p>
)}
</div>
)}
</div>
</aside>
);
}
return (
<div className="j13-hide-block" data-hide-kind={kind}>
<div className="j13-hide-block-bar">
<span className="j13-hide-badge">{label}</span>
{kind === "points" && points > 0 && (
<span className="meta text-[12px]">{points} 积分</span>
)}
</div>
<div className="j13-hide-block-body">{children}</div>
</div>
);
}