feat(attach): 付费附件游客跳登录,积分数独立价签并升级下载交互
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -404,6 +404,16 @@ body {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
.j13-compose-att-paid {
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.j13-compose-att-paid .j13-compose-field {
|
||||||
|
color: var(--gold);
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
border-color: color-mix(in srgb, var(--gold) 38%, var(--line-2));
|
||||||
|
}
|
||||||
.j13-interact-card {
|
.j13-interact-card {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -1708,6 +1718,295 @@ body {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 帖子附件:积分数独立成列,付费卡用金色语义 */
|
||||||
|
.j13-att {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.35rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.j13-att-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.j13-att-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-att-title svg {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.j13-att-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.j13-att-card {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 46px minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 14px;
|
||||||
|
row-gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
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);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
|
||||||
|
}
|
||||||
|
.j13-att-card:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.j13-att-card.is-paid {
|
||||||
|
border-color: color-mix(in srgb, var(--gold) 34%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--gold-soft) 48%, var(--panel));
|
||||||
|
}
|
||||||
|
.j13-att-card.is-paid:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--gold) 52%, var(--line));
|
||||||
|
}
|
||||||
|
.j13-att-card.is-unlocked {
|
||||||
|
border-color: color-mix(in srgb, var(--ok) 30%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--ok-soft) 42%, var(--panel));
|
||||||
|
}
|
||||||
|
.j13-att-card.is-unlocked: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);
|
||||||
|
}
|
||||||
|
.j13-att-card.is-fresh {
|
||||||
|
animation: j13-att-unlock 0.7s ease-out;
|
||||||
|
}
|
||||||
|
.j13-att-type {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--tint);
|
||||||
|
}
|
||||||
|
.j13-att-type[data-kind="image"] {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.j13-att-type[data-kind="archive"] {
|
||||||
|
color: var(--gold);
|
||||||
|
background: var(--gold-soft);
|
||||||
|
}
|
||||||
|
.j13-att-type[data-kind="doc"] {
|
||||||
|
color: var(--clay);
|
||||||
|
background: var(--clay-soft);
|
||||||
|
}
|
||||||
|
.j13-att-type[data-kind="media"] {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
}
|
||||||
|
.j13-att-main {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.j13-att-name {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-att-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.j13-att-price {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 5.25rem;
|
||||||
|
padding: 8px 12px 7px;
|
||||||
|
border-radius: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--gold) 42%, var(--line));
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.j13-att-price-num {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--gold);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.j13-att-price-unit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
.j13-att-price.is-unlocked {
|
||||||
|
border-color: color-mix(in srgb, var(--ok) 42%, var(--line));
|
||||||
|
}
|
||||||
|
.j13-att-price.is-unlocked .j13-att-price-unit {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.j13-att-price.is-free {
|
||||||
|
min-width: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
.j13-att-price.is-free .j13-att-price-unit {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.j13-att-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.j13-att-cta.is-primary {
|
||||||
|
border-color: transparent;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-on);
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.12),
|
||||||
|
0 2px 8px color-mix(in srgb, var(--accent) 26%, transparent);
|
||||||
|
}
|
||||||
|
.j13-att-pay {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.j13-att-pay-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.j13-att-pay-name {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-att-pay-hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 18px 8px 6px;
|
||||||
|
}
|
||||||
|
.j13-att-pay-num {
|
||||||
|
display: block;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--gold);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.j13-att-pay-unit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
@keyframes j13-att-unlock {
|
||||||
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 38%, transparent); }
|
||||||
|
100% { box-shadow: 0 0 0 12px transparent; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.j13-att-card {
|
||||||
|
grid-template-columns: 46px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.j13-att-cta {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.j13-att-card,
|
||||||
|
.j13-att-card.is-fresh {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.j13-att-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.j13-compose-shell {
|
.j13-compose-shell {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -4440,7 +4739,8 @@ html.j13-reduce-chrome .j13-toast-in,
|
|||||||
html.j13-reduce-chrome .j13-heart-pop,
|
html.j13-reduce-chrome .j13-heart-pop,
|
||||||
html.j13-reduce-chrome .j13-pulse-dot,
|
html.j13-reduce-chrome .j13-pulse-dot,
|
||||||
html.j13-reduce-chrome .j13-tip-bubble,
|
html.j13-reduce-chrome .j13-tip-bubble,
|
||||||
html.j13-reduce-chrome .j13-tip-in {
|
html.j13-reduce-chrome .j13-tip-in,
|
||||||
|
html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -727,7 +727,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}
|
components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PostAttachments postId={post.id} items={post.attachments || []} />
|
<PostAttachments
|
||||||
|
postId={post.id}
|
||||||
|
items={post.attachments || []}
|
||||||
|
loggedIn={!!me.user}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 类型特殊块(投票/悬赏/抽奖/问答)统一放正文最下方 */}
|
{/* 类型特殊块(投票/悬赏/抽奖/问答)统一放正文最下方 */}
|
||||||
<PostInteractPanels
|
<PostInteractPanels
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Download } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
import { postAttachmentDownloadURL, type PostAttachment } from "@/lib/api";
|
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 { toast } from "@/lib/toast";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
|
||||||
function formatSize(n: number) {
|
function formatSize(n: number) {
|
||||||
if (n < 1024) return `${n} B`;
|
if (n < 1024) return `${n} B`;
|
||||||
@@ -10,57 +14,319 @@ function formatSize(n: number) {
|
|||||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
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({
|
export default function PostAttachments({
|
||||||
postId,
|
postId,
|
||||||
items,
|
items,
|
||||||
|
loggedIn,
|
||||||
}: {
|
}: {
|
||||||
postId: number;
|
postId: number;
|
||||||
items: PostAttachment[];
|
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;
|
if (!items.length) return null;
|
||||||
|
|
||||||
const onClick = (a: PostAttachment) => {
|
const isUnlocked = (a: PostAttachment) => a.unlocked || unlockedIds.includes(a.id);
|
||||||
if (a.price_points > 0 && !a.unlocked) {
|
|
||||||
// 仍走下载 URL:后端会扣积分或 402
|
const download = async (a: PostAttachment) => {
|
||||||
toast(`将消耗 ${a.price_points} 积分下载`, "ok");
|
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 (
|
return (
|
||||||
<section className="mt-8 pt-6" style={{ borderTop: "1px solid var(--line)" }}>
|
<section className="j13-att" aria-labelledby="j13-att-title">
|
||||||
<h2 className="font-extrabold text-[15px] mb-3" style={{ color: "var(--ink)" }}>
|
<header className="j13-att-head">
|
||||||
附件
|
<h2 id="j13-att-title" className="j13-att-title">
|
||||||
</h2>
|
<Paperclip size={15} strokeWidth={2.25} aria-hidden />
|
||||||
<ul className="space-y-2">
|
附件
|
||||||
{items.map((a) => (
|
</h2>
|
||||||
<li key={a.id}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onClick(a)}
|
className="btn btn-line btn-sm"
|
||||||
className="w-full flex items-center gap-3 px-3.5 py-3 rounded-[12px] text-left transition-colors hover:bg-[var(--accent-soft)]"
|
disabled={busyId != null}
|
||||||
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
|
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>
|
</button>
|
||||||
</li>
|
<button
|
||||||
))}
|
type="button"
|
||||||
</ul>
|
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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1220,7 +1220,11 @@ export default function PostComposer(props: PostComposerProps) {
|
|||||||
<div className="truncate text-[13.5px]">{a.name}</div>
|
<div className="truncate text-[13.5px]">{a.name}</div>
|
||||||
<div className="meta text-[12px]">{formatSize(a.size)}</div>
|
<div className="meta text-[12px]">{formatSize(a.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="meta text-[12px] flex items-center gap-1.5 shrink-0">
|
<label
|
||||||
|
className={`meta text-[12px] flex items-center gap-1.5 shrink-0${
|
||||||
|
a.price_points > 0 ? " j13-compose-att-paid" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
积分
|
积分
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -1231,6 +1235,7 @@ export default function PostComposer(props: PostComposerProps) {
|
|||||||
onChange={(e) => void setAttPrice(a.id, Number(e.target.value))}
|
onChange={(e) => void setAttPrice(a.id, Number(e.target.value))}
|
||||||
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
|
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
|
||||||
data-tip="0 = 免费下载"
|
data-tip="0 = 免费下载"
|
||||||
|
aria-label={`${a.name} 下载积分,0 为免费`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1066,6 +1066,45 @@ export function postAttachmentDownloadURL(postId: number | string, attId: number
|
|||||||
return `/api/posts/${postId}/attachments/${attId}/download`;
|
return `/api/posts/${postId}/attachments/${attId}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filenameFromDisposition(header: string | null, fallback: string): string {
|
||||||
|
if (!header) return fallback;
|
||||||
|
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header);
|
||||||
|
if (star?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(star[1]);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const quoted = /filename\s*=\s*"([^"]+)"/i.exec(header);
|
||||||
|
if (quoted?.[1]) return quoted[1];
|
||||||
|
const plain = /filename\s*=\s*([^;]+)/i.exec(header);
|
||||||
|
if (plain?.[1]) return plain[1].trim();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载帖子附件:走 fetch 以便处理 401/402 JSON,避免整页打开错误文档 */
|
||||||
|
export async function apiDownloadPostAttachment(
|
||||||
|
postId: number | string,
|
||||||
|
attId: number,
|
||||||
|
fallbackName: string
|
||||||
|
): Promise<{ blob?: Blob; filename?: string; error?: string; status?: number }> {
|
||||||
|
const res = await fetchWithRefresh(postAttachmentDownloadURL(postId, attId), {
|
||||||
|
method: "GET",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
return { error: data.error || "下载失败", status: res.status };
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
filename: filenameFromDisposition(res.headers.get("Content-Disposition"), fallbackName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiVotePoll(postId: string, options: number[]) {
|
export async function apiVotePoll(postId: string, options: number[]) {
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}/poll/vote`, {
|
const res = await fetchWithRefresh(`/api/posts/${postId}/poll/vote`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in New Issue
Block a user