fix(attach): 附件改回浏览器下载通道,仅按钮触发且不再误报已开始
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user