diff --git a/.gitignore b/.gitignore index 9a4df574..caac79ac 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ go.work go.work.sum /credit main + +# upload +uploads/* diff --git a/config.example.yaml b/config.example.yaml index 696e0ad5..2cfd59db 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -109,6 +109,7 @@ scheduler: auto_refund_expired_disputes_task_cron: "0 0 * * *" sync_orders_to_clickhouse_task_cron: "10 0 * * *" refund_expired_red_envelopes_task_cron: "0 1 * * *" + cleanup_unused_uploads_task_cron: "0 */2 * * *" # Worker worker: diff --git a/docs/docs.go b/docs/docs.go index 042652d0..a67bf347 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1470,6 +1470,33 @@ const docTemplate = `{ } } }, + "/api/v1/redenvelope/covers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/redenvelope/create": { "post": { "consumes": [ @@ -1561,6 +1588,43 @@ const docTemplate = `{ } } }, + "/api/v1/upload/redenvelope/cover": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "file", + "description": "图片文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/pay-key": { "put": { "consumes": [ @@ -1593,6 +1657,30 @@ const docTemplate = `{ } } }, + "/f/{id}": { + "get": { + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "string", + "description": "Upload ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/pay/distribute": { "post": { "consumes": [ @@ -2221,10 +2309,18 @@ const docTemplate = `{ "type" ], "properties": { + "cover_upload_id": { + "type": "string", + "example": "0" + }, "greeting": { "type": "string", "maxLength": 100 }, + "heterotypic_upload_id": { + "type": "string", + "example": "0" + }, "pay_key": { "type": "string", "maxLength": 10 diff --git a/docs/swagger.json b/docs/swagger.json index 871e27d7..178602b1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1461,6 +1461,33 @@ } } }, + "/api/v1/redenvelope/covers": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/redenvelope/create": { "post": { "consumes": [ @@ -1552,6 +1579,43 @@ } } }, + "/api/v1/upload/redenvelope/cover": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "file", + "description": "图片文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/pay-key": { "put": { "consumes": [ @@ -1584,6 +1648,30 @@ } } }, + "/f/{id}": { + "get": { + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "string", + "description": "Upload ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/pay/distribute": { "post": { "consumes": [ @@ -2212,10 +2300,18 @@ "type" ], "properties": { + "cover_upload_id": { + "type": "string", + "example": "0" + }, "greeting": { "type": "string", "maxLength": 100 }, + "heterotypic_upload_id": { + "type": "string", + "example": "0" + }, "pay_key": { "type": "string", "maxLength": 10 diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b9c055b2..d1c7d932 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -371,9 +371,15 @@ definitions: type: object redenvelope.CreateRequest: properties: + cover_upload_id: + example: "0" + type: string greeting: maxLength: 100 type: string + heterotypic_upload_id: + example: "0" + type: string pay_key: maxLength: 10 type: string @@ -1457,6 +1463,23 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - redenvelope + /api/v1/redenvelope/covers: + get: + parameters: + - description: 封面类型 (cover/heterotypic) + in: query + name: type + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope /api/v1/redenvelope/create: post: consumes: @@ -1497,6 +1520,30 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - redenvelope + /api/v1/upload/redenvelope/cover: + post: + consumes: + - multipart/form-data + parameters: + - description: 图片文件 + in: formData + name: file + required: true + type: file + - description: 封面类型 (cover/heterotypic) + in: formData + name: type + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - upload /api/v1/user/pay-key: put: consumes: @@ -1517,6 +1564,21 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - user + /f/{id}: + get: + parameters: + - description: Upload ID + in: path + name: id + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: OK + tags: + - upload /pay/distribute: post: consumes: diff --git a/frontend/app/(pay)/redenvelope/[id]/page.tsx b/frontend/app/(pay)/redenvelope/[id]/page.tsx index c4020f98..6ab5510a 100644 --- a/frontend/app/(pay)/redenvelope/[id]/page.tsx +++ b/frontend/app/(pay)/redenvelope/[id]/page.tsx @@ -1,5 +1,31 @@ +import type { Metadata } from "next" +import { headers } from "next/headers" import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim" +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + const { id } = await params + const requestHeaders = await headers() + const protocol = requestHeaders.get("x-forwarded-proto") ?? "https" + const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "" + const baseUrl = host ? `${protocol}://${host}` : "" + const title = "你收到一个LDC红包" + const description = "点击打开领取" + const url = baseUrl ? `${baseUrl}/redenvelope/${id}` : undefined + const image = baseUrl ? `${baseUrl}/red-envelope.svg` : "/red-envelope.svg" + + return { + title, + description, + openGraph: { + title, + description, + url, + type: "website", + images: [{ url: image, width: 384, height: 512, alt: "红包" }], + } + } +} + export default async function RedEnvelopePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params return diff --git a/frontend/components/common/redenvelope/image-cropper.tsx b/frontend/components/common/redenvelope/image-cropper.tsx new file mode 100644 index 00000000..8eccaef2 --- /dev/null +++ b/frontend/components/common/redenvelope/image-cropper.tsx @@ -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 { + 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/png') +} + +/** + * 创建图片对象 + */ +function createImage(url: string): Promise { + 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(null) + const [crop, setCrop] = useState({ x: 0, y: 0 }) + const [zoom, setZoom] = useState(1) + const [rotation, setRotation] = useState(0) + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) + const fileInputRef = React.useRef(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) => { + 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 ( + + + + + {image ? `裁剪${ coverLabel }` : `上传${ coverLabel }`} + + + {image ? '拖拽调整位置,滚轮缩放' : '推荐 2:3 比例,支持 JPG / PNG / WEBP,最大 2MB'} + + + + {!image ? ( + /* 上传区域 */ +
+ + +
+ ) : ( + /* 裁剪区域 */ +
+
+ +
+ +
+
+ + setZoom(value[0])} + min={1} + max={3} + step={0.1} + className="flex-1" + /> + {Math.round(zoom * 100)}% +
+ +
+ +
+ + setRotation(value[0])} + min={0} + max={360} + step={1} + className="flex-1" + /> + {rotation}° +
+ +
+ +
+ + +
+
+
+ )} + + + + + + +
+ ) +} \ No newline at end of file diff --git a/frontend/components/common/redenvelope/red-envelope-card.tsx b/frontend/components/common/redenvelope/red-envelope-card.tsx new file mode 100644 index 00000000..2987ba7b --- /dev/null +++ b/frontend/components/common/redenvelope/red-envelope-card.tsx @@ -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 ( + + {/* 上半部分 */} +
+ {/* 自定义背景封面 */} + {coverImage ? ( +
+ 红包封面 + {/* 半透明遮罩以确保内容可读 */} +
+
+ ) : null} + + {/* 用户信息 */} +
+ + + + + {sender?.username?.charAt(0).toUpperCase()} + + + {sender?.username || '你'} 的红包 + + + + {greeting || "新年快乐,恭喜发财"} + +
+
+ + {/* 开启按钮 */} +
+ {status === "opening" && ( + +
+ + )} + + + + 開 + + +
+
+ ) +} diff --git a/frontend/components/common/redenvelope/red-envelope-claim.tsx b/frontend/components/common/redenvelope/red-envelope-claim.tsx index 3d29091e..58a672e1 100644 --- a/frontend/components/common/redenvelope/red-envelope-claim.tsx +++ b/frontend/components/common/redenvelope/red-envelope-claim.tsx @@ -2,14 +2,17 @@ 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 { Button } from "@/components/ui/button" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { ScrollArea } from "@/components/ui/scroll-area" 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 @@ -23,6 +26,54 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { const [claimedAmount, setClaimedAmount] = useState(null) const [error, setError] = useState(null) + 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) @@ -42,6 +93,29 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { } }, [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]) @@ -106,205 +180,171 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { return (
- {/* 统一卡片容器 - 响应式尺寸 */} - - - {(state === "ready" || state === "opening") && ( - -
- -
- -
-
-
-
-
- -
- - - - - {envelope?.creator_username?.charAt(0).toUpperCase()} - - - - - {envelope?.creator_username} 的红包 - -
- -
- {state === "opening" && ( - -
- - )} - - -
- - 開 - - - {state === "ready" && ( - - )} - -
- -
- - {envelope?.greeting || "恭喜发财,大吉大利"} - -
- - )} - - {(state === "claimed" || state === "opened") && ( - -
-
-
-
-
+ {/* 装饰图片容器 */} +
+ {/* 异形装饰 */} + {heterotypicImage && ( + + 红包装饰 + + )} + + {/* 统一卡片容器 */} + + + {(state === "ready" || state === "opening") && ( + + )} - - - - - {envelope?.creator_username?.charAt(0).toUpperCase()} - - - -

{envelope?.creator_username} 的红包

-

- {envelope?.greeting || "恭喜发财,大吉大利"} -

-
- - {claimedAmount && ( -
- + {/* 顶部区域 */} +
+
-

{parseFloat(claimedAmount).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2,})}

-

LDC

- + {coverImage && ( +
+ cover +
+ )} +
- )} - -
-
- - {detail?.claims.length || 0}/{envelope?.total_count} 个红包已领取 - - - {envelope?.remaining_amount}/{envelope?.total_amount} LDC - + + {/* 个人信息 */} +
+
+ + + + {envelope?.creator_username?.charAt(0).toUpperCase()} + + + {envelope?.creator_username} 发出的红包 + {envelope?.type === 'random' && ( + + )} +
+

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

- -
- {detail?.claims.map((claim: RedEnvelopeClaim) => ( - -
- - - - {claim.username.charAt(0).toUpperCase()} - - - {claim.username} + {/* 金额区域 */} +
+ {claimedAmount ? ( +
+
+
+ {parseFloat(claimedAmount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + LDC
- {parseFloat(claim.amount).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2,})} LDC - - ))} +
+
+ ) : ( +
+

手慢了,红包派完了

+
+ )} +
+ + {/* 列表区域 */} +
+
+ {detail?.claims.length || 0}个红包共{parseFloat(envelope?.total_amount || "0").toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC,{ + envelope?.remaining_count === 0 ? "已被抢光" : "继续等待领取" + }
- -
- -
- -
- - )} - - +
+ +
+ {detail?.claims.map((claim: RedEnvelopeClaim) => ( +
+
+ + + + {claim.username.charAt(0).toUpperCase()} + + +
+ {claim.username} + + {formatClaimedAt(claim.claimed_at)} + +
+
+
+ {parseFloat(claim.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC + {/* 最佳手气 */} + {bestClaimId === claim.id && ( +
+ 手气最佳 +
+ )} +
+
+ ))} +
+
+
+ + )} + + +
) -} \ No newline at end of file +} diff --git a/frontend/components/common/trade/red-envelope.tsx b/frontend/components/common/trade/red-envelope.tsx index aab8a158..f7527297 100644 --- a/frontend/components/common/trade/red-envelope.tsx +++ b/frontend/components/common/trade/red-envelope.tsx @@ -2,8 +2,11 @@ import * as React from "react" import { useState, useEffect } from "react" +import Image from "next/image" import { toast } from "sonner" -import { Gift, Copy, Check, ExternalLink } from "lucide-react" +import { Gift, Copy, Check, ExternalLink, Pencil, X, ImagePlus, History } from "lucide-react" +import type { UploadImageResponse } from "@/lib/services" +import { getFileUrl } from "@/lib/services/upload/upload.service" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Button } from "@/components/ui/button" @@ -12,19 +15,36 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { PasswordDialog } from "@/components/common/general/password-dialog" import { ListData } from "@/components/common/general/list-data" +import { ImageCropper } from "@/components/common/redenvelope/image-cropper" +import { motion } from "motion/react" +import { RedEnvelopeCard } from "@/components/common/redenvelope/red-envelope-card" +import { useUser } from "@/contexts/user-context" import services from "@/lib/services" import type { RedEnvelopeType, CreateRedEnvelopeRequest, PublicConfigResponse, RedEnvelope, RedEnvelopeListResponse } from "@/lib/services" +interface RedEnvelopeCover { + coverImageUrl: string | null + heterotypicImageUrl: string | null + coverUploadId: string | null + heterotypicUploadId: string | null +} + /** * 红包组件 * * 提供红包发送功能,包括 Banner 和创建对话框 */ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { + const { user } = useUser() const [isFormOpen, setIsFormOpen] = useState(false) const [isPasswordOpen, setIsPasswordOpen] = useState(false) const [isResultOpen, setIsResultOpen] = useState(false) const [copiedId, setCopiedId] = useState(null) + const [isCropperOpen, setIsCropperOpen] = useState(false) + const [cropperType, setCropperType] = useState<'cover' | 'heterotypic'>('cover') + const [showCustomization, setShowCustomization] = useState(false) + const [historyCoverCovers, setHistoryCoverCovers] = useState([]) + const [historyHeterotypicCovers, setHistoryHeterotypicCovers] = useState([]) /* 表单状态 */ const [type, setType] = useState("random") @@ -33,6 +53,14 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { const [greeting, setGreeting] = useState("") const [loading, setLoading] = useState(false) + /* 封面状态 */ + const [cover, setCover] = useState({ + coverImageUrl: null, + heterotypicImageUrl: null, + coverUploadId: null, + heterotypicUploadId: null + }) + /* 结果状态 */ const [resultLink, setResultLink] = useState("") @@ -85,13 +113,11 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { const amount = parseFloat(totalAmount) - // 检查红包最低金额限制(1 LDC) if (amount < 1) { toast.error("红包总金额不能低于1 LDC") return } - - // 检查红包最大金额限制 + if (config && parseFloat(config.red_envelope_max_amount) > 0) { if (amount > parseFloat(config.red_envelope_max_amount)) { toast.error(`红包的积分总数不能超过 ${ config.red_envelope_max_amount } LDC`) @@ -104,11 +130,6 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { return } - if (amount < 1) { - toast.error("红包的积分总数不能小于 1 LDC") - return - } - if (amount / count < 0.01) { toast.error("单个红包不可低于 0.01 LDC") return @@ -132,6 +153,8 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { total_count: parseInt(totalCount), greeting: greeting || "恭喜发财,大吉大利", pay_key: password, + cover_upload_id: cover.coverUploadId || undefined, + heterotypic_upload_id: cover.heterotypicUploadId || undefined, } const result = await services.redEnvelope.create(data) @@ -148,6 +171,13 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { setTotalAmount("") setTotalCount("") setGreeting("") + setCover({ + coverImageUrl: null, + heterotypicImageUrl: null, + coverUploadId: null, + heterotypicUploadId: null + }) + setShowCustomization(false) } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : '创建失败' @@ -190,6 +220,95 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { return `/redenvelope/${ id }` } + /* 处理封面裁剪完成 */ + const handleCropComplete = async (croppedImage: string, coverType: 'cover' | 'heterotypic') => { + const uploadingToast = toast.loading(`正在上传${ coverType === 'cover' ? '背景封面' : '装饰图片' }...`) + try { + const result = await services.upload.uploadBase64Image( + croppedImage, + coverType, + `${ coverType }-${ Date.now() }.png` + ) + + setCover(prev => ({ + ...prev, + [coverType === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: getFileUrl(result.id), + [coverType === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: result.id + })) + + toast.dismiss(uploadingToast) + toast.success(`${ coverType === 'cover' ? '背景封面' : '装饰图片' }已设置`) + } catch (error) { + toast.dismiss(uploadingToast) + const errorMessage = error instanceof Error ? error.message : '上传失败' + toast.error(`上传${ coverType === 'cover' ? '背景封面' : '装饰图片' }失败`, { + description: errorMessage + }) + } + } + + /* 加载历史封面 */ + const loadHistoryCovers = async () => { + try { + const [covers, heterotypics] = await Promise.all([ + services.redEnvelope.listCovers('cover'), + services.redEnvelope.listCovers('heterotypic'), + ]) + setHistoryCoverCovers(covers || []) + setHistoryHeterotypicCovers(heterotypics || []) + } catch { + // 静默失败,不影响主功能 + } + } + + /* 选择历史封面 */ + const handleSelectHistoryCover = (item: UploadImageResponse, type: 'cover' | 'heterotypic') => { + // 检查是否已经选中当前项 + const currentId = type === 'cover' ? cover.coverUploadId : cover.heterotypicUploadId + if (currentId === item.id) { + // 取消选择 + setCover(prev => ({ + ...prev, + [type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: null, + [type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: null + })) + toast.success(`已取消选择${ type === 'cover' ? '背景封面' : '装饰图片' }`) + return + } + + setCover(prev => ({ + ...prev, + [type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: getFileUrl(item.id), + [type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: item.id + })) + toast.success(`已选择历史${ type === 'cover' ? '背景封面' : '装饰图片' }`) + } + + /* 打开裁剪对话框 */ + const handleOpenCropper = (type: 'cover' | 'heterotypic') => { + setCropperType(type) + setIsCropperOpen(true) + } + + /* 移除封面 */ + const handleRemoveCover = (type: 'cover' | 'heterotypic') => { + setCover(prev => ({ + ...prev, + [type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: null, + [type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: null + })) + toast.success(`${ type === 'cover' ? '背景封面' : '装饰图片' }已移除`) + } + + /* 处理图片加载错误 */ + const handleImageError = (id: string, type: 'cover' | 'heterotypic') => { + if (type === 'cover') { + setHistoryCoverCovers(prev => prev.filter(item => item.id !== id)) + } else { + setHistoryHeterotypicCovers(prev => prev.filter(item => item.id !== id)) + } + } + return (
@@ -210,98 +329,330 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) {
- + - - - 发红包 + +
+ + 发红包 +
+
- 创建积分红包,支持固定金额和拼手气两种模式。 + 创建积分红包,支持固定金额和拼手气两种模式{showCustomization && ",可添加个性化封面"}。
-
-
- - -
- -
-
- - 0 ? config.red_envelope_max_amount : undefined} - placeholder={config && parseFloat(config.red_envelope_max_amount) > 0 ? `最大 ${ config.red_envelope_max_amount } LDC` : "0.00"} - value={totalAmount} - onChange={(e) => setTotalAmount(e.target.value)} - className="font-mono shadow-none" - disabled={loading} - /> +
+
+
+
+ + +
+ +
+ + 0 ? config.red_envelope_max_amount : undefined} + placeholder={config && parseFloat(config.red_envelope_max_amount) > 0 ? `最大 ${ config.red_envelope_max_amount }` : "0.00"} + value={totalAmount} + onChange={(e) => setTotalAmount(e.target.value)} + className="font-mono shadow-none" + disabled={loading} + /> +
+ +
+ + setTotalCount(e.target.value)} + className="font-mono shadow-none" + disabled={loading} + /> +
- - setTotalCount(e.target.value)} - className="font-mono shadow-none" + +