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;
color: inherit;
font: inherit;
cursor: pointer;
cursor: default;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow-sm);
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 {
transform: translateY(-1px);
box-shadow: var(--shadow);
@@ -1800,13 +1797,7 @@ a.j13-att-card:hover {
border-color: color-mix(in srgb, var(--ok) 46%, var(--line));
}
.j13-att-card.is-busy {
cursor: wait;
}
.j13-att-card:disabled {
opacity: 0.58;
cursor: not-allowed;
transform: none;
box-shadow: var(--shadow-sm);
opacity: 0.92;
}
.j13-att-card.is-fresh {
animation: j13-att-unlock 0.7s ease-out;
@@ -1923,11 +1914,25 @@ a.j13-att-card:hover {
font-weight: 600;
line-height: 1;
flex-shrink: 0;
pointer-events: none;
cursor: pointer;
border: 1px solid var(--line-2);
background: var(--panel);
color: var(--ink);
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 {
border-color: transparent;
@@ -1936,6 +1941,10 @@ a.j13-att-card:hover {
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12),
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 {
display: flex;
flex-direction: column;

View File

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