"use client" import * as React from "react" import { useState, useEffect, useCallback } from "react" 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" interface RedEnvelopeClaimProps { id: string } type ClaimState = "loading" | "ready" | "opening" | "opened" | "claimed" | "error" 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 bestClaimId = React.useMemo(() => { const claims = detail?.claims if (!claims || claims.length === 0) return null let topId: string | null = null let topAmount = -Infinity for (const claim of claims) { const amount = parseFloat(claim.amount) if (Number.isNaN(amount)) continue if (amount > topAmount) { topAmount = amount topId = claim.id } } 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) console.log('Red envelope data:', data.red_envelope) setDetail(data) if (data.user_claimed) { setClaimedAmount(data.user_claimed.amount) setState("claimed") } else if (data.red_envelope.status !== "active") { setState("opened") } else { setState("ready") } } catch (err) { setError(err instanceof Error ? err.message : "加载失败") setState("error") } }, [id]) // 安全验证图片URL (防止XSS) const sanitizeImageUrl = (url: string | null | undefined): string | undefined => { if (!url) return undefined // 允许相对路径且以 /f/ 开头 if (!url.startsWith('/f/')) { console.warn('Invalid image URL detected:', url) return undefined } // 防止路径遍历 if (url.includes('..') || url.includes('//')) { console.warn('Path traversal detected in URL:', url) return undefined } return url } // 从后端获取封面图片URL并进行安全验证 const coverImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.cover_upload_id)) const heterotypicImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.heterotypic_upload_id)) useEffect(() => { loadDetail() }, [loadDetail]) const handleOpen = async () => { setState("opening") try { const result = await services.redEnvelope.claim({ id }) setClaimedAmount(result.amount) // Reload the full details to get updated claims list const updatedDetail = await services.redEnvelope.getDetail(id) setDetail(updatedDetail) setTimeout(() => setState("claimed"), 1500) } catch (err) { toast.error(err instanceof Error ? err.message : "领取失败") setState("ready") } } if (state === "loading") { return (
) } if (state === "error") { return (

红包加载失败

{error}

) } const envelope = detail?.red_envelope const claimedCount = detail?.claims?.length ?? 0 const totalCount = envelope?.total_count ?? claimedCount const totalAmount = parseFloat(envelope?.total_amount || "0").toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return (
{/* 装饰图片容器 */}
{/* 异形装饰 */} {heterotypicImage && ( )} {/* 统一卡片容器 */} {(state === "ready" || state === "opening") && ( )} {(state === "claimed" || state === "opened") && ( {/* 顶部区域 */}
{coverImage && (
cover
)}
{/* 个人信息 */}
{envelope?.creator_username?.charAt(0).toUpperCase()}
{truncateText(envelope?.creator_username, 12)} 发出的红包
{envelope?.type === 'random' && ( 拼 )}

{envelope?.greeting || "新年快乐,恭喜发财"}

{/* 金额区域 */}
{claimedAmount ? (
{parseFloat(claimedAmount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC
) : (

手慢了,红包派完了

)}
{/* 列表区域 */}
共 {totalAmount} LDC
{claimedCount} / {totalCount} 个
{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 && (
手气最佳
)}
))}
)}
) }