- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
293 lines
9.1 KiB
TypeScript
293 lines
9.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|