Files
jiang13-bbs/frontend/components/PostAttachments.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

293 lines
9.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 { useEffect, useState, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, Coins, Loader2, Paperclip } from "lucide-react";
import {
apiDownloadPostAttachment,
postAttachmentDownloadURL,
type PostAttachment,
} from "@/lib/api";
import { fileExtBadge, fileKind } from "@/lib/fileKind";
import { toast } from "@/lib/toast";
import Modal from "@/components/Modal";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
function formatSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
function formatPoints(n: number) {
return n.toLocaleString("zh-CN");
}
/** 把已拿到的文件交给浏览器下载栏(付费首次须先 fetch 才能处理 402) */
function triggerBrowserDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
function AttachmentMeta({
a,
unlocked,
}: {
a: PostAttachment;
unlocked: boolean;
}) {
const paid = a.price_points > 0;
return (
<>
<span className="j13-att-type" data-kind={fileKind(a.name, a.mime)} aria-hidden>
{fileExtBadge(a.name)}
</span>
<span className="j13-att-main">
<span className="j13-att-name">{a.name}</span>
<span className="j13-att-meta">
{formatSize(a.size)}
{a.download_count > 0 ? ` · ${a.download_count} 次下载` : ""}
</span>
</span>
<span
className={`j13-att-price${paid ? (unlocked ? " is-unlocked" : "") : " is-free"}`}
aria-hidden
>
{paid ? (
<>
<span className="j13-att-price-num">{formatPoints(a.price_points)}</span>
<span className="j13-att-price-unit">
<Coins size={12} strokeWidth={2.4} />
{unlocked ? "已解锁" : "积分"}
</span>
</>
) : (
<span className="j13-att-price-unit">免费</span>
)}
</span>
</>
);
}
export default function PostAttachments({
postId,
items,
loggedIn,
}: {
postId: number;
items: PostAttachment[];
loggedIn: boolean;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
const loginHref = `/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`;
const [busyId, setBusyId] = useState<number | null>(null);
const [pending, setPending] = useState<PostAttachment | null>(null);
const [unlockedIds, setUnlockedIds] = useState<number[]>([]);
const [freshId, setFreshId] = useState<number | null>(null);
useEffect(() => {
if (freshId == null) return;
const t = window.setTimeout(() => setFreshId(null), 900);
return () => window.clearTimeout(t);
}, [freshId]);
if (!items.length) return null;
const isUnlocked = (a: PostAttachment) => a.unlocked || unlockedIds.includes(a.id);
/** 付费未解锁:先确认再 fetch,避免积分不足时整页 JSON;成功后交给浏览器下载栏 */
const downloadPaid = async (a: PostAttachment) => {
if (busyId != null) return;
setBusyId(a.id);
try {
const res = await apiDownloadPostAttachment(postId, a.id, a.name);
if (res.status === 401) {
setPending(null);
router.push(loginHref);
return;
}
if (res.error || !res.blob) {
toast(res.error || "下载失败");
return;
}
triggerBrowserDownload(res.blob, res.filename || a.name);
setPending(null);
setUnlockedIds((prev) => (prev.includes(a.id) ? prev : [...prev, a.id]));
setFreshId(a.id);
toast("已解锁", "ok");
router.refresh();
} catch {
toast("下载失败,请稍后重试");
} finally {
setBusyId(null);
}
};
const onPayCta = (a: PostAttachment) => {
if (busyId != null) return;
setPending(a);
};
return (
<section className="j13-att" aria-labelledby="j13-att-title">
<header className="j13-att-head">
<h2 id="j13-att-title" className="j13-att-title">
<Paperclip size={15} strokeWidth={2.25} aria-hidden />
附件
</h2>
<span className="meta text-[12.5px]">{items.length} 个文件</span>
</header>
<ul className="j13-att-list">
{items.map((a) => {
const guestPaid = !loggedIn && a.price_points > 0;
const unlocked = isUnlocked(a);
const busy = busyId === a.id;
const paidLocked = a.price_points > 0 && !unlocked;
const label = guestPaid
? `登录后下载 ${a.name},需 ${formatPoints(a.price_points)} 积分`
: paidLocked
? `支付 ${formatPoints(a.price_points)} 积分下载 ${a.name}`
: `下载 ${a.name}`;
const ctaText = guestPaid
? "登录后下载"
: busy
? "处理中…"
: paidLocked
? "支付下载"
: "下载";
const className = [
"j13-att-card",
a.price_points > 0 ? "is-paid" : "",
unlocked && a.price_points > 0 ? "is-unlocked" : "",
busy ? "is-busy" : "",
freshId === a.id ? "is-fresh" : "",
]
.filter(Boolean)
.join(" ");
const ctaClass = `j13-att-cta${paidLocked || guestPaid ? " is-primary" : ""}`;
const nativeHref = postAttachmentDownloadURL(postId, a.id);
let cta: ReactNode;
if (guestPaid) {
cta = (
<Link href={loginHref} className={ctaClass} aria-label={label}>
{ctaText}
</Link>
);
} else if (paidLocked) {
cta = (
<button
type="button"
className={ctaClass}
aria-label={label}
aria-busy={busy || undefined}
disabled={busyId != null}
onClick={() => onPayCta(a)}
>
{busy ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
{ctaText}
</button>
);
} else {
cta = (
<a href={nativeHref} className={ctaClass} aria-label={label} download={a.name}>
{unlocked && a.price_points > 0 ? <Check size={14} aria-hidden /> : null}
{ctaText}
</a>
);
}
return (
<li key={a.id}>
<article className={className}>
<AttachmentMeta a={a} unlocked={unlocked} />
{cta}
</article>
</li>
);
})}
</ul>
<Modal
open={!!pending}
title="确认支付"
description="支付一次后永久解锁,不重复扣费"
busy={busyId != null}
zIndexClass="z-[1100]"
maxWidthClass="max-w-sm"
panelClassName="p-5"
onClose={() => {
if (busyId != null) return;
setPending(null);
}}
footer={
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busyId != null}
onClick={() => {
if (busyId != null) return;
setPending(null);
}}
>
取消
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busyId != null || !pending}
onClick={() => {
if (!pending) return;
void downloadPaid(pending);
}}
>
{busyId != null ? (
<>
<Loader2 size={14} className="animate-spin" aria-hidden />
处理中…
</>
) : (
`支付 ${pending ? formatPoints(pending.price_points) : ""} 积分`
)}
</button>
</>
}
>
{pending ? (
<div className="j13-att-pay">
<div className="j13-att-pay-file">
<span
className="j13-att-type"
data-kind={fileKind(pending.name, pending.mime)}
aria-hidden
>
{fileExtBadge(pending.name)}
</span>
<div className="min-w-0">
<p className="j13-att-pay-name">{pending.name}</p>
<p className="meta text-[12px] !mb-0">{formatSize(pending.size)}</p>
</div>
</div>
<div className="j13-att-pay-hero">
<span className="j13-att-pay-num">{formatPoints(pending.price_points)}</span>
<span className="j13-att-pay-unit">
<Coins size={14} strokeWidth={2.4} aria-hidden />
积分
</span>
</div>
</div>
) : null}
</Modal>
</section>
);
}