diff --git a/frontend/components/common/redenvelope/red-envelope-avatar.tsx b/frontend/components/common/redenvelope/red-envelope-avatar.tsx new file mode 100644 index 00000000..465de6d6 --- /dev/null +++ b/frontend/components/common/redenvelope/red-envelope-avatar.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import { useInView } from "motion/react" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" + +interface RedEnvelopeAvatarProps { + src?: string + username?: string + className?: string + fallbackClassName?: string +} + +export function RedEnvelopeAvatar({ src, username, className, fallbackClassName }: RedEnvelopeAvatarProps) { + const avatarRef = React.useRef(null) + const isInView = useInView(avatarRef, { once: true }) + + return ( + + {/* Radix 会预加载图片,进入可视区域后才挂载,避免缓冲行提前请求头像。 */} + {isInView && ( + + )} + + {username?.charAt(0).toUpperCase()} + + + ) +} diff --git a/frontend/components/common/redenvelope/red-envelope-card.tsx b/frontend/components/common/redenvelope/red-envelope-card.tsx index f2531153..b3903e49 100644 --- a/frontend/components/common/redenvelope/red-envelope-card.tsx +++ b/frontend/components/common/redenvelope/red-envelope-card.tsx @@ -3,7 +3,7 @@ import * as React from "react" import Image from "next/image" import { motion } from "motion/react" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { RedEnvelopeAvatar } from "./red-envelope-avatar" export interface RedEnvelopeCardProps { status: "preview" | "ready" | "opening" @@ -27,6 +27,9 @@ export function RedEnvelopeCard({ onOpen, className }: RedEnvelopeCardProps) { + const [failedCoverImage, setFailedCoverImage] = React.useState(null) + const availableCoverImage = coverImage !== failedCoverImage ? coverImage : undefined + // 预览模式下不播放入场动画 const isPreview = status === "preview" @@ -43,20 +46,21 @@ export function RedEnvelopeCard({ className="absolute top-0 h-[78%] overflow-hidden z-10 w-full" style={{ borderRadius: '0 0 100% 100% / 0 0 20% 20%', - backgroundColor: coverImage ? '#f35543' : '#E75240', + backgroundColor: availableCoverImage ? '#f35543' : '#E75240', boxShadow: '0 2px 2px rgba(0,0,0,0.15)' }} > {/* 自定义背景封面 */} - {coverImage ? ( + {availableCoverImage ? (
红包封面 setFailedCoverImage(availableCoverImage)} /> {/* 半透明遮罩以确保内容可读 */}
@@ -71,12 +75,12 @@ export function RedEnvelopeCard({ transition={{ delay: 0.15, type: "spring", stiffness: 300, damping: 20 }} className="flex items-center gap-2 opacity-90" > - - - - {sender?.username?.charAt(0).toUpperCase()} - - + {sender?.username || '你'} 的红包 diff --git a/frontend/components/common/redenvelope/red-envelope-claim.tsx b/frontend/components/common/redenvelope/red-envelope-claim.tsx index 03b7e671..f044a471 100644 --- a/frontend/components/common/redenvelope/red-envelope-claim.tsx +++ b/frontend/components/common/redenvelope/red-envelope-claim.tsx @@ -2,16 +2,17 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" +import { useVirtualizer } from "@tanstack/react-virtual" import Image from "next/image" import { motion, AnimatePresence } from "motion/react" import { toast } from "sonner" import { Gift } from "lucide-react" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import services from "@/lib/services" import { formatDateTime } from "@/lib/utils" import type { RedEnvelopeDetailResponse, RedEnvelopeClaim } from "@/lib/services" import { getFileUrl } from "@/lib/services/upload/upload.service" import { RedEnvelopeCard } from "./red-envelope-card" +import { RedEnvelopeAvatar } from "./red-envelope-avatar" interface RedEnvelopeClaimProps { id: string @@ -19,17 +20,48 @@ interface RedEnvelopeClaimProps { type ClaimState = "loading" | "ready" | "opening" | "opened" | "claimed" | "error" +const truncateText = (value: string | undefined, maxChars: number) => { + if (!value) return "" + const chars = Array.from(value) + if (chars.length <= maxChars) return value + return `${ chars.slice(0, maxChars).join("") }…` +} + +const formatClaimedAt = (claimedAt?: string) => { + if (!claimedAt) return "-" + + const date = new Date(claimedAt) + if (Number.isNaN(date.getTime())) return "-" + + const getShanghaiDateKey = (d: Date) => + new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit" + }).format(d) + + const todayKey = getShanghaiDateKey(new Date()) + const claimKey = getShanghaiDateKey(date) + + if (claimKey === todayKey) { + return new Intl.DateTimeFormat("zh-CN", { + timeZone: "Asia/Shanghai", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(date) + } + + return formatDateTime(date) +} + export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { const [state, setState] = useState("loading") const [detail, setDetail] = useState(null) const [claimedAmount, setClaimedAmount] = useState(null) const [error, setError] = useState(null) - const truncateText = (value: string | undefined, maxChars: number) => { - if (!value) return "" - const chars = Array.from(value) - if (chars.length <= maxChars) return value - return `${ chars.slice(0, maxChars).join("") }…` - } + const [failedCoverImage, setFailedCoverImage] = useState(null) const bestClaimId = React.useMemo(() => { const claims = detail?.claims @@ -50,35 +82,6 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { return topId }, [detail?.claims]) - const formatClaimedAt = (claimedAt?: string) => { - if (!claimedAt) return "-" - - const date = new Date(claimedAt) - if (Number.isNaN(date.getTime())) return "-" - - const getShanghaiDateKey = (d: Date) => - new Intl.DateTimeFormat("en-CA", { - timeZone: "Asia/Shanghai", - year: "numeric", - month: "2-digit", - day: "2-digit" - }).format(d) - - const todayKey = getShanghaiDateKey(new Date()) - const claimKey = getShanghaiDateKey(date) - - if (claimKey === todayKey) { - return new Intl.DateTimeFormat("zh-CN", { - timeZone: "Asia/Shanghai", - hour: "2-digit", - minute: "2-digit", - hour12: false - }).format(date) - } - - return formatDateTime(date) - } - const loadDetail = useCallback(async () => { try { const data = await services.redEnvelope.getDetail(id) @@ -118,7 +121,8 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { } // 从后端获取封面图片URL并进行安全验证 - const coverImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.cover_upload_id)) + const coverImageUrl = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.cover_upload_id)) + const coverImage = coverImageUrl !== failedCoverImage ? coverImageUrl : undefined const heterotypicImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.heterotypic_upload_id)) useEffect(() => { @@ -258,11 +262,12 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { > cover setFailedCoverImage(coverImage)} />
)} @@ -272,12 +277,12 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { {/* 个人信息 */}
- - - - {envelope?.creator_username?.charAt(0).toUpperCase()} - - +
{truncateText(envelope?.creator_username, 12)} @@ -323,44 +328,10 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) {
-
-
- {detail?.claims.map((claim: RedEnvelopeClaim) => ( -
-
- - - - {claim.username.charAt(0).toUpperCase()} - - -
- - {truncateText(claim.username, 12)} - - - {formatClaimedAt(claim.claimed_at)} - -
-
-
- - {parseFloat(claim.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC - - {/* 最佳手气 */} - {envelope?.type === 'random' && bestClaimId === claim.id && ( -
- 手气最佳 -
- )} -
-
- ))} -
-
+
)} @@ -371,3 +342,74 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) {
) } + +function RedEnvelopeClaimsList({ claims, bestClaimId }: { claims: RedEnvelopeClaim[], bestClaimId: string | null }) { + const scrollRef = React.useRef(null) + const getItemKey = useCallback((index: number) => claims[index].id, [claims]) + const virtualizer = useVirtualizer({ + count: claims.length, + getScrollElement: () => scrollRef.current, + getItemKey, + estimateSize: () => 56, + overscan: 5, + useFlushSync: false, + }) + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const claim = claims[virtualRow.index] + + return ( +
+
+ +
+ + {truncateText(claim.username, 12)} + + + {formatClaimedAt(claim.claimed_at)} + +
+
+
+ + {parseFloat(claim.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC + + {bestClaimId === claim.id && ( +
+ 手气最佳 +
+ )} +
+
+ ) + })} +
+
+ ) +}