feat(attach): 付费附件游客跳登录,积分数独立价签并升级下载交互
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { postAttachmentDownloadURL, type PostAttachment } from "@/lib/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Coins, Loader2, Paperclip } from "lucide-react";
|
||||
import { apiDownloadPostAttachment, type PostAttachment } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
@@ -10,57 +14,319 @@ function formatSize(n: number) {
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatPoints(n: number) {
|
||||
return n.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function fileExt(name: string) {
|
||||
const i = name.lastIndexOf(".");
|
||||
if (i <= 0 || i === name.length - 1) return "FILE";
|
||||
const ext = name.slice(i + 1).replace(/[^a-zA-Z0-9]/g, "");
|
||||
if (!ext || ext.length > 5) return "FILE";
|
||||
return ext.toUpperCase();
|
||||
}
|
||||
|
||||
function fileKind(name: string, mime: string): "image" | "archive" | "doc" | "media" | "file" {
|
||||
const m = (mime || "").toLowerCase();
|
||||
const ext = (name.split(".").pop() || "").toLowerCase();
|
||||
if (
|
||||
m.startsWith("image/") ||
|
||||
["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "ico", "heic"].includes(ext)
|
||||
) {
|
||||
return "image";
|
||||
}
|
||||
if (
|
||||
m.includes("zip") ||
|
||||
m.includes("rar") ||
|
||||
m.includes("7z") ||
|
||||
m.includes("compress") ||
|
||||
["zip", "rar", "7z", "tar", "gz", "bz2", "xz"].includes(ext)
|
||||
) {
|
||||
return "archive";
|
||||
}
|
||||
if (
|
||||
m.startsWith("audio/") ||
|
||||
m.startsWith("video/") ||
|
||||
["mp3", "wav", "flac", "aac", "ogg", "mp4", "mkv", "webm", "mov", "avi"].includes(ext)
|
||||
) {
|
||||
return "media";
|
||||
}
|
||||
if (
|
||||
m.includes("pdf") ||
|
||||
m.includes("word") ||
|
||||
m.includes("text") ||
|
||||
m.includes("sheet") ||
|
||||
m.includes("excel") ||
|
||||
["pdf", "doc", "docx", "txt", "md", "xls", "xlsx", "ppt", "pptx", "csv"].includes(ext)
|
||||
) {
|
||||
return "doc";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
function saveBlob(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();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function AttachmentFace({
|
||||
a,
|
||||
unlocked,
|
||||
busy,
|
||||
guestPaid,
|
||||
}: {
|
||||
a: PostAttachment;
|
||||
unlocked: boolean;
|
||||
busy: boolean;
|
||||
guestPaid: boolean;
|
||||
}) {
|
||||
const paid = a.price_points > 0;
|
||||
const cta = guestPaid
|
||||
? "登录后下载"
|
||||
: busy
|
||||
? "处理中…"
|
||||
: paid && !unlocked
|
||||
? "支付下载"
|
||||
: "下载";
|
||||
return (
|
||||
<>
|
||||
<span className="j13-att-type" data-kind={fileKind(a.name, a.mime)} aria-hidden>
|
||||
{fileExt(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>
|
||||
<span className={`j13-att-cta${paid && !unlocked ? " is-primary" : ""}`}>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
||||
{!busy && unlocked && paid ? <Check size={14} aria-hidden /> : null}
|
||||
{cta}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PostAttachments({
|
||||
postId,
|
||||
items,
|
||||
loggedIn,
|
||||
}: {
|
||||
postId: number;
|
||||
items: PostAttachment[];
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const loginHref = `/login?redirect=${encodeURIComponent(`/post/${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 onClick = (a: PostAttachment) => {
|
||||
if (a.price_points > 0 && !a.unlocked) {
|
||||
// 仍走下载 URL:后端会扣积分或 402
|
||||
toast(`将消耗 ${a.price_points} 积分下载`, "ok");
|
||||
const isUnlocked = (a: PostAttachment) => a.unlocked || unlockedIds.includes(a.id);
|
||||
|
||||
const download = async (a: PostAttachment) => {
|
||||
if (busyId != null) return;
|
||||
const firstPaid = a.price_points > 0 && !isUnlocked(a);
|
||||
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;
|
||||
}
|
||||
saveBlob(res.blob, res.filename || a.name);
|
||||
setPending(null);
|
||||
if (firstPaid) {
|
||||
setUnlockedIds((prev) => (prev.includes(a.id) ? prev : [...prev, a.id]));
|
||||
setFreshId(a.id);
|
||||
toast("已解锁并开始下载", "ok");
|
||||
} else {
|
||||
toast("已开始下载", "ok");
|
||||
}
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast("下载失败,请稍后重试");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
window.location.href = postAttachmentDownloadURL(postId, a.id);
|
||||
};
|
||||
|
||||
const onActivate = (a: PostAttachment) => {
|
||||
if (busyId != null) return;
|
||||
if (a.price_points > 0 && !isUnlocked(a)) {
|
||||
setPending(a);
|
||||
return;
|
||||
}
|
||||
void download(a);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-8 pt-6" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<h2 className="font-extrabold text-[15px] mb-3" style={{ color: "var(--ink)" }}>
|
||||
附件
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{items.map((a) => (
|
||||
<li key={a.id}>
|
||||
<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
|
||||
? `下载 ${a.name},需 ${formatPoints(a.price_points)} 积分`
|
||||
: a.price_points > 0
|
||||
? `下载 ${a.name}(已解锁)`
|
||||
: `下载 ${a.name}(免费)`;
|
||||
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 face = (
|
||||
<AttachmentFace a={a} unlocked={unlocked} busy={busy} guestPaid={guestPaid} />
|
||||
);
|
||||
return (
|
||||
<li key={a.id}>
|
||||
{guestPaid ? (
|
||||
<Link href={loginHref} className={className} aria-label={label}>
|
||||
{face}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
aria-label={label}
|
||||
aria-busy={busy || undefined}
|
||||
disabled={busyId != null}
|
||||
onClick={() => onActivate(a)}
|
||||
>
|
||||
{face}
|
||||
</button>
|
||||
)}
|
||||
</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"
|
||||
onClick={() => onClick(a)}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-3 rounded-[12px] text-left transition-colors hover:bg-[var(--accent-soft)]"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busyId != null}
|
||||
onClick={() => {
|
||||
if (busyId != null) return;
|
||||
setPending(null);
|
||||
}}
|
||||
>
|
||||
<Download size={16} style={{ color: "var(--accent)" }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{a.name}
|
||||
</div>
|
||||
<div className="meta text-[12px]">
|
||||
{formatSize(a.size)}
|
||||
{a.price_points > 0
|
||||
? a.unlocked
|
||||
? " · 已解锁"
|
||||
: ` · ${a.price_points} 积分`
|
||||
: " · 免费"}
|
||||
{a.download_count > 0 ? ` · ${a.download_count} 次下载` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="btn btn-line btn-sm shrink-0">下载</span>
|
||||
取消
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busyId != null || !pending}
|
||||
onClick={() => {
|
||||
if (!pending) return;
|
||||
void download(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
|
||||
>
|
||||
{fileExt(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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user