fix(attach): 附件改回浏览器下载通道,仅按钮触发且不再误报已开始

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 06:28:33 +08:00
parent dfc0beafe9
commit 73fbbbafef
2 changed files with 84 additions and 73 deletions

View File

@@ -1767,15 +1767,12 @@ body {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
font: inherit; font: inherit;
cursor: pointer; cursor: default;
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s, background 0.15s; transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s, background 0.15s;
} }
a.j13-att-card:hover {
color: inherit;
}
.j13-att-card:hover { .j13-att-card:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow); box-shadow: var(--shadow);
@@ -1800,13 +1797,7 @@ a.j13-att-card:hover {
border-color: color-mix(in srgb, var(--ok) 46%, var(--line)); border-color: color-mix(in srgb, var(--ok) 46%, var(--line));
} }
.j13-att-card.is-busy { .j13-att-card.is-busy {
cursor: wait; opacity: 0.92;
}
.j13-att-card:disabled {
opacity: 0.58;
cursor: not-allowed;
transform: none;
box-shadow: var(--shadow-sm);
} }
.j13-att-card.is-fresh { .j13-att-card.is-fresh {
animation: j13-att-unlock 0.7s ease-out; animation: j13-att-unlock 0.7s ease-out;
@@ -1923,11 +1914,25 @@ a.j13-att-card:hover {
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
flex-shrink: 0; flex-shrink: 0;
pointer-events: none; cursor: pointer;
border: 1px solid var(--line-2); border: 1px solid var(--line-2);
background: var(--panel); background: var(--panel);
color: var(--ink); color: var(--ink);
white-space: nowrap; white-space: nowrap;
font-family: inherit;
text-decoration: none;
appearance: none;
}
a.j13-att-cta:hover {
color: var(--ink);
}
.j13-att-cta:hover:not(:disabled):not(.is-primary) {
border-color: var(--accent);
color: var(--accent);
}
.j13-att-cta:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
.j13-att-cta.is-primary { .j13-att-cta.is-primary {
border-color: transparent; border-color: transparent;
@@ -1936,6 +1941,10 @@ a.j13-att-card:hover {
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12), box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12),
0 2px 8px color-mix(in srgb, var(--accent) 26%, transparent); 0 2px 8px color-mix(in srgb, var(--accent) 26%, transparent);
} }
.j13-att-cta.is-primary:hover:not(:disabled) {
background: var(--accent-hi);
color: var(--accent-on);
}
.j13-att-pay { .j13-att-pay {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,10 +1,14 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, type ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check, Coins, Loader2, Paperclip } from "lucide-react"; import { Check, Coins, Loader2, Paperclip } from "lucide-react";
import { apiDownloadPostAttachment, type PostAttachment } from "@/lib/api"; import {
apiDownloadPostAttachment,
postAttachmentDownloadURL,
type PostAttachment,
} from "@/lib/api";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
@@ -64,7 +68,8 @@ function fileKind(name: string, mime: string): "image" | "archive" | "doc" | "me
return "file"; return "file";
} }
function saveBlob(blob: Blob, filename: string) { /** 把已拿到的文件交给浏览器下载栏(付费首次须先 fetch 才能处理 402) */
function triggerBrowserDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -73,28 +78,17 @@ function saveBlob(blob: Blob, filename: string) {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
URL.revokeObjectURL(url); window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
} }
function AttachmentFace({ function AttachmentMeta({
a, a,
unlocked, unlocked,
busy,
guestPaid,
}: { }: {
a: PostAttachment; a: PostAttachment;
unlocked: boolean; unlocked: boolean;
busy: boolean;
guestPaid: boolean;
}) { }) {
const paid = a.price_points > 0; const paid = a.price_points > 0;
const cta = guestPaid
? "登录后下载"
: busy
? "处理中…"
: paid && !unlocked
? "支付下载"
: "下载";
return ( return (
<> <>
<span className="j13-att-type" data-kind={fileKind(a.name, a.mime)} aria-hidden> <span className="j13-att-type" data-kind={fileKind(a.name, a.mime)} aria-hidden>
@@ -123,11 +117,6 @@ function AttachmentFace({
<span className="j13-att-price-unit">免费</span> <span className="j13-att-price-unit">免费</span>
)} )}
</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>
</> </>
); );
} }
@@ -158,9 +147,9 @@ export default function PostAttachments({
const isUnlocked = (a: PostAttachment) => a.unlocked || unlockedIds.includes(a.id); const isUnlocked = (a: PostAttachment) => a.unlocked || unlockedIds.includes(a.id);
const download = async (a: PostAttachment) => { /** 付费未解锁:先确认再 fetch,避免积分不足时整页 JSON;成功后交给浏览器下载栏 */
const downloadPaid = async (a: PostAttachment) => {
if (busyId != null) return; if (busyId != null) return;
const firstPaid = a.price_points > 0 && !isUnlocked(a);
setBusyId(a.id); setBusyId(a.id);
try { try {
const res = await apiDownloadPostAttachment(postId, a.id, a.name); const res = await apiDownloadPostAttachment(postId, a.id, a.name);
@@ -173,15 +162,11 @@ export default function PostAttachments({
toast(res.error || "下载失败"); toast(res.error || "下载失败");
return; return;
} }
saveBlob(res.blob, res.filename || a.name); triggerBrowserDownload(res.blob, res.filename || a.name);
setPending(null); setPending(null);
if (firstPaid) { setUnlockedIds((prev) => (prev.includes(a.id) ? prev : [...prev, a.id]));
setUnlockedIds((prev) => (prev.includes(a.id) ? prev : [...prev, a.id])); setFreshId(a.id);
setFreshId(a.id); toast("已解锁", "ok");
toast("已解锁并开始下载", "ok");
} else {
toast("已开始下载", "ok");
}
router.refresh(); router.refresh();
} catch { } catch {
toast("下载失败,请稍后重试"); toast("下载失败,请稍后重试");
@@ -190,13 +175,9 @@ export default function PostAttachments({
} }
}; };
const onActivate = (a: PostAttachment) => { const onPayCta = (a: PostAttachment) => {
if (busyId != null) return; if (busyId != null) return;
if (a.price_points > 0 && !isUnlocked(a)) { setPending(a);
setPending(a);
return;
}
void download(a);
}; };
return ( return (
@@ -217,10 +198,15 @@ export default function PostAttachments({
const label = guestPaid const label = guestPaid
? `登录后下载 ${a.name},需 ${formatPoints(a.price_points)} 积分` ? `登录后下载 ${a.name},需 ${formatPoints(a.price_points)} 积分`
: paidLocked : paidLocked
? `下载 ${a.name},需 ${formatPoints(a.price_points)} 积分` ? `支付 ${formatPoints(a.price_points)} 积分下载 ${a.name}`
: a.price_points > 0 : `下载 ${a.name}`;
? `下载 ${a.name}(已解锁)` const ctaText = guestPaid
: `下载 ${a.name}(免费)`; ? "登录后下载"
: busy
? "处理中…"
: paidLocked
? "支付下载"
: "下载";
const className = [ const className = [
"j13-att-card", "j13-att-card",
a.price_points > 0 ? "is-paid" : "", a.price_points > 0 ? "is-paid" : "",
@@ -230,27 +216,43 @@ export default function PostAttachments({
] ]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
const face = ( const ctaClass = `j13-att-cta${paidLocked || guestPaid ? " is-primary" : ""}`;
<AttachmentFace a={a} unlocked={unlocked} busy={busy} guestPaid={guestPaid} /> 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 ( return (
<li key={a.id}> <li key={a.id}>
{guestPaid ? ( <article className={className}>
<Link href={loginHref} className={className} aria-label={label}> <AttachmentMeta a={a} unlocked={unlocked} />
{face} {cta}
</Link> </article>
) : (
<button
type="button"
className={className}
aria-label={label}
aria-busy={busy || undefined}
disabled={busyId != null}
onClick={() => onActivate(a)}
>
{face}
</button>
)}
</li> </li>
); );
})} })}
@@ -287,7 +289,7 @@ export default function PostAttachments({
disabled={busyId != null || !pending} disabled={busyId != null || !pending}
onClick={() => { onClick={() => {
if (!pending) return; if (!pending) return;
void download(pending); void downloadPaid(pending);
}} }}
> >
{busyId != null ? ( {busyId != null ? (