mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useCallback } from "react"
|
||||
import Cropper from "react-easy-crop"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Crop, RotateCw, X } from "lucide-react"
|
||||
import type { Area, Point } from "react-easy-crop"
|
||||
|
||||
interface ImageCropperProps {
|
||||
/** 是否打开对话框 */
|
||||
isOpen: boolean
|
||||
/** 关闭对话框回调 */
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** 裁剪完成回调 */
|
||||
onCropComplete: (croppedImage: string, coverType: 'cover' | 'heterotypic') => void
|
||||
/** 封面类型 */
|
||||
coverType: 'cover' | 'heterotypic'
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建裁剪后的图片
|
||||
*/
|
||||
async function getCroppedImg(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
rotation = 0
|
||||
): Promise<string> {
|
||||
const image = await createImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建 canvas context')
|
||||
}
|
||||
|
||||
const maxSize = Math.max(image.width, image.height)
|
||||
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2))
|
||||
|
||||
canvas.width = safeArea
|
||||
canvas.height = safeArea
|
||||
|
||||
ctx.translate(safeArea / 2, safeArea / 2)
|
||||
ctx.rotate((rotation * Math.PI) / 180)
|
||||
ctx.translate(-safeArea / 2, -safeArea / 2)
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
safeArea / 2 - image.width * 0.5,
|
||||
safeArea / 2 - image.height * 0.5
|
||||
)
|
||||
|
||||
const data = ctx.getImageData(0, 0, safeArea, safeArea)
|
||||
|
||||
canvas.width = pixelCrop.width
|
||||
canvas.height = pixelCrop.height
|
||||
|
||||
ctx.putImageData(
|
||||
data,
|
||||
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
|
||||
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
|
||||
)
|
||||
|
||||
return canvas.toDataURL('image/webp')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建图片对象
|
||||
*/
|
||||
function createImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener('load', () => resolve(image))
|
||||
image.addEventListener('error', (error) => reject(error))
|
||||
image.setAttribute('crossOrigin', 'anonymous')
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片裁剪组件
|
||||
* 使用 react-easy-crop 实现专业的图片裁剪功能
|
||||
*/
|
||||
export function ImageCropper({ isOpen, onOpenChange, onCropComplete, coverType }: ImageCropperProps) {
|
||||
const [image, setImage] = useState<string | null>(null)
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const resetCropState = () => {
|
||||
setImage(null)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
setRotation(0)
|
||||
setCroppedAreaPixels(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 裁剪比例配置 - 2:3
|
||||
const aspect = 2 / 3
|
||||
const cropShape = 'rect'
|
||||
|
||||
const coverLabel = coverType === 'cover' ? '背景封面' : '装饰图片'
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小 (最大 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('图片大小不能超过 2MB')
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
resetCropState()
|
||||
setImage(e.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
// 裁剪完成回调
|
||||
const onCropCompleteHandler = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels)
|
||||
}, [])
|
||||
|
||||
// 执行裁剪
|
||||
const handleCrop = async () => {
|
||||
if (!image || !croppedAreaPixels) return
|
||||
|
||||
try {
|
||||
const croppedImage = await getCroppedImg(image, croppedAreaPixels, rotation)
|
||||
onCropComplete(croppedImage, coverType)
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
console.error('裁剪失败:', error)
|
||||
toast.error('裁剪失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
resetCropState()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className={`max-h-[90vh] overflow-y-auto w-[95vw] sm:w-full ${ image ? 'sm:max-w-2xl' : 'sm:max-w-md' }`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{image ? `裁剪${ coverLabel }` : `上传${ coverLabel }`}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{image ? '拖拽调整位置,滚轮缩放' : '推荐 2:3 比例,支持 JPG / PNG / WEBP,最大 2MB'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!image ? (
|
||||
/* 上传区域 */
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="image-upload"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="image-upload"
|
||||
className="cursor-pointer block rounded-lg border border-dashed border-border hover:border-foreground/25 hover:bg-muted/50 transition-colors p-10 sm:p-14 text-center"
|
||||
>
|
||||
<Crop className="size-6 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-xs font-medium text-foreground">点击选择图片</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
或拖拽图片到此处
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
) : (
|
||||
/* 裁剪区域 */
|
||||
<div className="space-y-3">
|
||||
<div className="relative w-full h-[300px] sm:h-[420px] bg-muted/50 rounded-lg overflow-hidden touch-none">
|
||||
<Cropper
|
||||
image={image}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
rotation={rotation}
|
||||
aspect={aspect}
|
||||
cropShape={cropShape}
|
||||
showGrid={true}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropCompleteHandler}
|
||||
onZoomChange={setZoom}
|
||||
onRotationChange={setRotation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 px-1">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Label className="text-[10px] text-muted-foreground shrink-0">缩放</Label>
|
||||
<Slider
|
||||
value={[zoom]}
|
||||
onValueChange={(value: number[]) => setZoom(value[0])}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-7 text-right shrink-0">{Math.round(zoom * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border shrink-0" />
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Label className="text-[10px] text-muted-foreground shrink-0">旋转</Label>
|
||||
<Slider
|
||||
value={[rotation]}
|
||||
onValueChange={(value: number[]) => setRotation(value[0])}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-6 text-right shrink-0">{rotation}°</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border shrink-0" />
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setRotation((prev) => (prev + 90) % 360)}
|
||||
className="size-7 text-muted-foreground"
|
||||
title="旋转 90°"
|
||||
>
|
||||
<RotateCw className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={resetCropState}
|
||||
className="size-7 text-muted-foreground"
|
||||
title="重新选择"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleClose} className="text-xs h-8">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCrop}
|
||||
disabled={!image}
|
||||
className="bg-red-500 hover:bg-red-600 text-xs h-8"
|
||||
>
|
||||
确认裁剪
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import { motion } from "motion/react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
|
||||
export interface RedEnvelopeCardProps {
|
||||
status: "preview" | "ready" | "opening"
|
||||
coverImage?: string | null
|
||||
heterotypicImage?: string | null
|
||||
greeting?: string
|
||||
sender?: {
|
||||
username?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
onOpen?: () => void
|
||||
amount?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RedEnvelopeCard({
|
||||
status,
|
||||
coverImage,
|
||||
greeting,
|
||||
sender,
|
||||
onOpen,
|
||||
className
|
||||
}: RedEnvelopeCardProps) {
|
||||
// 预览模式下不播放入场动画
|
||||
const isPreview = status === "preview"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layoutId={!isPreview ? "red-envelope-card" : undefined}
|
||||
className={`relative w-full h-full rounded-xl shadow-2xl overflow-hidden z-10 bg-[#F25542] ${ className || "" }`}
|
||||
initial={!isPreview ? { opacity: 1 } : undefined}
|
||||
animate={!isPreview ? { opacity: 1 } : undefined}
|
||||
exit={!isPreview ? { y: -40, opacity: 0, scale: 0.95, transition: { duration: 0.5, ease: "easeInOut" } } : undefined}
|
||||
>
|
||||
{/* 上半部分 */}
|
||||
<div
|
||||
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',
|
||||
boxShadow: '0 2px 2px rgba(0,0,0,0.15)'
|
||||
}}
|
||||
>
|
||||
{/* 自定义背景封面 */}
|
||||
{coverImage ? (
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt="红包封面"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
loading="eager"
|
||||
/>
|
||||
{/* 半透明遮罩以确保内容可读 */}
|
||||
<div className="absolute inset-0 bg-black/30 dark:bg-black/50" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 用户信息 */}
|
||||
<div className="absolute top-28 left-0 right-0 flex flex-col items-center justify-center text-[#FAE5AE] z-20 px-6 space-y-4">
|
||||
<motion.div
|
||||
initial={!isPreview ? { scale: 0 } : undefined}
|
||||
animate={!isPreview ? { scale: 1 } : undefined}
|
||||
transition={{ delay: 0.15, type: "spring", stiffness: 300, damping: 20 }}
|
||||
className="flex items-center gap-2 opacity-90"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-md border border-white/20 shadow-sm">
|
||||
<AvatarImage src={sender?.avatar_url} alt={sender?.username} />
|
||||
<AvatarFallback className="bg-[#FAE5AE] text-[#E75240] font-bold text-xs rounded-md">
|
||||
{sender?.username?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-base font-medium tracking-wide">{sender?.username || '你'} 的红包</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={!isPreview ? { opacity: 0, y: 10 } : undefined}
|
||||
animate={!isPreview ? { opacity: 1, y: 0 } : undefined}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-xl font-medium tracking-wider text-center opacity-90"
|
||||
>
|
||||
{greeting || "新年快乐,恭喜发财"}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 开启按钮 */}
|
||||
<div className="absolute top-[78%] left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
|
||||
{status === "opening" && (
|
||||
<motion.div
|
||||
className="absolute inset-0 -m-8"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: [0.9, 2.0], opacity: [0, 0.6, 0] }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full rounded-full"
|
||||
style={{ background: "radial-gradient(circle, rgba(255,232,168,0.8) 0%, rgba(245,205,122,0.4) 35%, transparent 65%)" }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.button
|
||||
className="relative w-20 h-20 rounded-full bg-[#EFC77A] flex items-center justify-center shadow-lg cursor-pointer border-none focus:outline-none overflow-hidden group"
|
||||
whileHover={status === "ready" || isPreview ? { scale: 1.05 } : {}}
|
||||
whileTap={status === "ready" || isPreview ? { scale: 0.95 } : {}}
|
||||
animate={status === "opening" ? {
|
||||
scale: [1, 0.92, 0],
|
||||
opacity: [1, 0]
|
||||
} : {}}
|
||||
transition={status === "opening" ? {
|
||||
duration: 0.55,
|
||||
ease: "easeInOut"
|
||||
} : {
|
||||
duration: 0.2
|
||||
}}
|
||||
onClick={status === "ready" ? onOpen : undefined}
|
||||
disabled={status !== "ready" && !isPreview}
|
||||
>
|
||||
<span className="relative text-[#4D4753] font-bold text-3xl z-10" style={{ fontFamily: 'serif' }}>
|
||||
開
|
||||
</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"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<ClaimState>("loading")
|
||||
const [detail, setDetail] = useState<RedEnvelopeDetailResponse | null>(null)
|
||||
const [claimedAmount, setClaimedAmount] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1 }}>
|
||||
<Gift className="h-12 w-12 text-red-500" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
>
|
||||
<div className="w-full rounded-3xl p-8 max-w-lg">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
|
||||
<Gift className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-4">
|
||||
红包加载失败
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="relative h-screen w-full flex flex-col items-center justify-center bg-background p-2 sm:p-4 overflow-hidden">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center">
|
||||
{/* 装饰图片容器 */}
|
||||
<div className="relative w-full max-w-[320px] aspect-[3/5] h-auto flex items-center justify-center">
|
||||
{/* 异形装饰 */}
|
||||
{heterotypicImage && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="absolute inset-0 z-0 pointer-events-none flex items-center justify-center scale-[1.2]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Image
|
||||
src={heterotypicImage}
|
||||
alt="红包装饰"
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
unoptimized
|
||||
loading="eager"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 统一卡片容器 */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut", delay: 0.1 }}
|
||||
className="relative w-full h-full mx-auto rounded-xl shadow-2xl overflow-hidden z-10"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{(state === "ready" || state === "opening") && (
|
||||
<RedEnvelopeCard
|
||||
key="cover"
|
||||
status={state}
|
||||
coverImage={coverImage}
|
||||
greeting={envelope?.greeting}
|
||||
sender={{
|
||||
username: envelope?.creator_username,
|
||||
avatar_url: envelope?.creator_avatar_url
|
||||
}}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(state === "claimed" || state === "opened") && (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ y: 30, opacity: 0, scale: 0.98 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
>
|
||||
{/* 顶部区域 */}
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className="h-26 w-full bg-[#F25542] relative z-0"
|
||||
style={{
|
||||
borderRadius: '0 0 100% 100% / 0 0 80% 80%'
|
||||
}}
|
||||
>
|
||||
{coverImage && (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ borderRadius: '0 0 100% 100% / 0 0 80% 80%' }}
|
||||
>
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt="cover"
|
||||
fill
|
||||
className="object-cover opacity-50"
|
||||
style={{ transform: "scale(1.1)", transformOrigin: "center" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 个人信息 */}
|
||||
<div className="flex flex-col items-center justify-center mt-4 shrink-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Avatar className="h-6 w-6 rounded-md border border-white/20">
|
||||
<AvatarImage className="rounded-md" src={envelope?.creator_avatar_url} alt={envelope?.creator_username} />
|
||||
<AvatarFallback className="bg-[#E75240] text-white text-[10px] rounded-md">
|
||||
{envelope?.creator_username?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-foreground/80 truncate">
|
||||
{truncateText(envelope?.creator_username, 12)}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground/80 whitespace-nowrap">发出的红包</span>
|
||||
</div>
|
||||
{envelope?.type === 'random' && (
|
||||
<span className="bg-[#E1B876] text-white text-[10px] px-1 rounded">拼</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{envelope?.greeting || "新年快乐,恭喜发财"}</p>
|
||||
</div>
|
||||
|
||||
{/* 金额区域 */}
|
||||
<div className="bg-background pt-3 pb-4 px-4 text-center shrink-0">
|
||||
{claimedAmount ? (
|
||||
<div>
|
||||
<div className="text-center">
|
||||
<div className="relative inline-block text-[#CFB073] leading-none">
|
||||
<span className="text-5xl font-bold">{parseFloat(claimedAmount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||
<span className="absolute left-full ml-1 bottom-1 text-sm font-medium">LDC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
<p className="text-2xl font-medium text-foreground/80">手慢了,红包派完了</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 列表区域 */}
|
||||
<div className="flex-1 bg-background flex flex-col min-h-0">
|
||||
<div className="px-4 py-1 bg-background text-xs text-muted-foreground flex items-center justify-between">
|
||||
<div className="whitespace-nowrap">
|
||||
共 {totalAmount} LDC
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="font-semibold tabular-nums">{claimedCount}</span>
|
||||
<span>/</span>
|
||||
<span className="font-semibold tabular-nums">{totalCount}</span>
|
||||
<span>个</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/30" />
|
||||
<div className="flex-1 bg-background overflow-y-auto overscroll-contain">
|
||||
<div className="divide-y divide-border/30">
|
||||
{detail?.claims.map((claim: RedEnvelopeClaim) => (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="flex items-start justify-between px-4 py-2 gap-3"
|
||||
>
|
||||
<div className="flex gap-3 min-w-0 flex-1">
|
||||
<Avatar className="h-9 w-9 rounded-md border border-border/10 mt-0.5">
|
||||
<AvatarImage className="rounded-md" src={claim.avatar_url} alt={claim.username} />
|
||||
<AvatarFallback className="text-xs bg-muted rounded-md">
|
||||
{claim.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start gap-0.5 min-w-0">
|
||||
<span className="text-[13px] font-medium text-foreground/90 truncate max-w-[160px] sm:max-w-[200px]">
|
||||
{truncateText(claim.username, 12)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatClaimedAt(claim.claimed_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0 min-w-[96px] text-right">
|
||||
<span className="text-[13px] font-semibold text-foreground tabular-nums whitespace-nowrap">
|
||||
{parseFloat(claim.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC
|
||||
</span>
|
||||
{/* 最佳手气 */}
|
||||
{envelope?.type === 'random' && bestClaimId === claim.id && (
|
||||
<div className="flex items-center gap-1 text-[#E1B876] text-[9px]">
|
||||
<span>手气最佳</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user