diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 184529e..f22ee30 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -404,6 +404,16 @@ body { border: 1px solid var(--line); 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 { padding: 14px 16px; border-radius: 14px; @@ -1708,6 +1718,295 @@ body { 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 { background: var(--panel); 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-pulse-dot, 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; transition: none !important; } diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index 844d75a..d2e259d 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -727,7 +727,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }} /> - + {/* 类型特殊块(投票/悬赏/抽奖/问答)统一放正文最下方 */} 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 ( + <> + + {fileExt(a.name)} + + + {a.name} + + {formatSize(a.size)} + {a.download_count > 0 ? ` · ${a.download_count} 次下载` : ""} + + + + {paid ? ( + <> + {formatPoints(a.price_points)} + + + {unlocked ? "已解锁" : "积分"} + + + ) : ( + 免费 + )} + + + {busy ? : null} + {!busy && unlocked && paid ? : null} + {cta} + + + ); +} + 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(null); + const [pending, setPending] = useState(null); + const [unlockedIds, setUnlockedIds] = useState([]); + const [freshId, setFreshId] = useState(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 ( -
-

- 附件 -

-
    - {items.map((a) => ( -
  • +
    +
    +

    + + 附件 +

    + {items.length} 个文件 +
    +
      + {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 = ( + + ); + return ( +
    • + {guestPaid ? ( + + {face} + + ) : ( + + )} +
    • + ); + })} +
    + + { + if (busyId != null) return; + setPending(null); + }} + footer={ + <> -
  • - ))} -
+ + + } + > + {pending ? ( +
+
+ + {fileExt(pending.name)} + +
+

{pending.name}

+

{formatSize(pending.size)}

+
+
+
+ {formatPoints(pending.price_points)} + + + 积分 + +
+
+ ) : null} +
); } diff --git a/frontend/components/PostComposer.tsx b/frontend/components/PostComposer.tsx index 227733f..70e62c3 100644 --- a/frontend/components/PostComposer.tsx +++ b/frontend/components/PostComposer.tsx @@ -1220,7 +1220,11 @@ export default function PostComposer(props: PostComposerProps) {
{a.name}
{formatSize(a.size)}
-