diff --git a/config.example.yaml b/config.example.yaml index af36a527..696e0ad5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -15,6 +15,7 @@ app: session_secure: false session_http_only: false api_prefix: "/api" + frontend_url: "http://localhost:3000" frontend_pay_url: "http://localhost:3000/paying" # OAuth2/OIDC(优先) @@ -107,6 +108,7 @@ scheduler: dispute_auto_refund_dispatch_interval_seconds: 3 auto_refund_expired_disputes_task_cron: "0 0 * * *" sync_orders_to_clickhouse_task_cron: "10 0 * * *" + refund_expired_red_envelopes_task_cron: "0 1 * * *" # Worker worker: diff --git a/docs/docs.go b/docs/docs.go index 5adcf3e8..1755f78f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1315,6 +1315,129 @@ const docTemplate = `{ } } }, + "/api/v1/redenvelope/claim": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "领取红包请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.ClaimRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/create": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "创建红包请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.CreateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/list": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "列表请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.ListRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "红包ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/pay-key": { "put": { "consumes": [ @@ -1624,6 +1747,17 @@ const docTemplate = `{ "PayLevelPremium" ] }, + "model.RedEnvelopeType": { + "type": "string", + "enum": [ + "fixed", + "random" + ], + "x-enum-varnames": [ + "RedEnvelopeTypeFixed", + "RedEnvelopeTypeRandom" + ] + }, "oauth.CallbackRequest": { "type": "object", "properties": { @@ -1690,7 +1824,10 @@ const docTemplate = `{ "community", "online", "test", - "distribute" + "distribute", + "red_envelope_send", + "red_envelope_receive", + "red_envelope_refund" ] } } @@ -1881,6 +2018,80 @@ const docTemplate = `{ } } }, + "redenvelope.ClaimRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "0" + } + } + }, + "redenvelope.CreateRequest": { + "type": "object", + "required": [ + "pay_key", + "total_amount", + "total_count", + "type" + ], + "properties": { + "greeting": { + "type": "string", + "maxLength": 100 + }, + "pay_key": { + "type": "string", + "maxLength": 10 + }, + "total_amount": { + "type": "number" + }, + "total_count": { + "type": "integer", + "minimum": 1 + }, + "type": { + "enum": [ + "fixed", + "random" + ], + "allOf": [ + { + "$ref": "#/definitions/model.RedEnvelopeType" + } + ] + } + } + }, + "redenvelope.ListRequest": { + "type": "object", + "required": [ + "page", + "page_size" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "type": { + "type": "string", + "enum": [ + "sent", + "received" + ] + } + } + }, "system_config.CreateSystemConfigRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index f675cf91..cb81c8d1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1306,6 +1306,129 @@ } } }, + "/api/v1/redenvelope/claim": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "领取红包请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.ClaimRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/create": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "创建红包请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.CreateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/list": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "description": "列表请求", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/redenvelope.ListRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/redenvelope/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "红包ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/pay-key": { "put": { "consumes": [ @@ -1615,6 +1738,17 @@ "PayLevelPremium" ] }, + "model.RedEnvelopeType": { + "type": "string", + "enum": [ + "fixed", + "random" + ], + "x-enum-varnames": [ + "RedEnvelopeTypeFixed", + "RedEnvelopeTypeRandom" + ] + }, "oauth.CallbackRequest": { "type": "object", "properties": { @@ -1681,7 +1815,10 @@ "community", "online", "test", - "distribute" + "distribute", + "red_envelope_send", + "red_envelope_receive", + "red_envelope_refund" ] } } @@ -1872,6 +2009,80 @@ } } }, + "redenvelope.ClaimRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "0" + } + } + }, + "redenvelope.CreateRequest": { + "type": "object", + "required": [ + "pay_key", + "total_amount", + "total_count", + "type" + ], + "properties": { + "greeting": { + "type": "string", + "maxLength": 100 + }, + "pay_key": { + "type": "string", + "maxLength": 10 + }, + "total_amount": { + "type": "number" + }, + "total_count": { + "type": "integer", + "minimum": 1 + }, + "type": { + "enum": [ + "fixed", + "random" + ], + "allOf": [ + { + "$ref": "#/definitions/model.RedEnvelopeType" + } + ] + } + } + }, + "redenvelope.ListRequest": { + "type": "object", + "required": [ + "page", + "page_size" + ], + "properties": { + "page": { + "type": "integer", + "minimum": 1 + }, + "page_size": { + "type": "integer", + "maximum": 100, + "minimum": 1 + }, + "type": { + "type": "string", + "enum": [ + "sent", + "received" + ] + } + } + }, "system_config.CreateSystemConfigRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 07233aa5..875c7460 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -146,6 +146,14 @@ definitions: - PayLevelBasic - PayLevelStandard - PayLevelPremium + model.RedEnvelopeType: + enum: + - fixed + - random + type: string + x-enum-varnames: + - RedEnvelopeTypeFixed + - RedEnvelopeTypeRandom oauth.CallbackRequest: properties: code: @@ -196,6 +204,9 @@ definitions: - online - test - distribute + - red_envelope_send + - red_envelope_receive + - red_envelope_refund type: string type: object payment.CreateOrderRequest: @@ -330,6 +341,57 @@ definitions: - recipient_id - recipient_username type: object + redenvelope.ClaimRequest: + properties: + id: + example: "0" + type: string + required: + - id + type: object + redenvelope.CreateRequest: + properties: + greeting: + maxLength: 100 + type: string + pay_key: + maxLength: 10 + type: string + total_amount: + type: number + total_count: + minimum: 1 + type: integer + type: + allOf: + - $ref: '#/definitions/model.RedEnvelopeType' + enum: + - fixed + - random + required: + - pay_key + - total_amount + - total_count + - type + type: object + redenvelope.ListRequest: + properties: + page: + minimum: 1 + type: integer + page_size: + maximum: 100 + minimum: 1 + type: integer + type: + enum: + - sent + - received + type: string + required: + - page + - page_size + type: object system_config.CreateSystemConfigRequest: properties: description: @@ -1260,6 +1322,83 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - payment + /api/v1/redenvelope/{id}: + get: + parameters: + - description: 红包ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope + /api/v1/redenvelope/claim: + post: + consumes: + - application/json + parameters: + - description: 领取红包请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/redenvelope.ClaimRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope + /api/v1/redenvelope/create: + post: + consumes: + - application/json + parameters: + - description: 创建红包请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/redenvelope.CreateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope + /api/v1/redenvelope/list: + post: + consumes: + - application/json + parameters: + - description: 列表请求 + in: body + name: request + required: true + schema: + $ref: '#/definitions/redenvelope.ListRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - redenvelope /api/v1/user/pay-key: put: consumes: diff --git a/frontend/app/(pay)/redenvelope/[id]/page.tsx b/frontend/app/(pay)/redenvelope/[id]/page.tsx new file mode 100644 index 00000000..1e677f73 --- /dev/null +++ b/frontend/app/(pay)/redenvelope/[id]/page.tsx @@ -0,0 +1,10 @@ +import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim" + +interface Props { + params: Promise<{ id: string }> +} + +export default async function RedEnvelopePage({ params }: Props) { + const { id } = await params + return +} \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css index badcf6e4..5b192e7d 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -133,7 +133,7 @@ } @theme inline { - /* + /* * Aurora 动画配置 */ --animate-aurora: aurora 120s linear infinite; @@ -151,4 +151,27 @@ 350% 50%; } } + + /* + * 红包动画配置 + */ + --animate-redenvelope-shake: redenvelope-shake 0.5s ease-in-out; + --animate-redenvelope-glow: redenvelope-glow 2s ease-in-out infinite; + --animate-gold-particle: gold-particle 1s ease-out forwards; + + @keyframes redenvelope-shake { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-5deg); } + 75% { transform: rotate(5deg); } + } + + @keyframes redenvelope-glow { + 0%, 100% { box-shadow: 0 0 20px rgba(255, 215, 0, 0.3); } + 50% { box-shadow: 0 0 40px rgba(255, 215, 0, 0.6); } + } + + @keyframes gold-particle { + 0% { transform: translateY(0) scale(1); opacity: 1; } + 100% { transform: translateY(-100px) scale(0); opacity: 0; } + } } \ No newline at end of file diff --git a/frontend/components/common/general/password-dialog.tsx b/frontend/components/common/general/password-dialog.tsx index 3867deed..f193487b 100644 --- a/frontend/components/common/general/password-dialog.tsx +++ b/frontend/components/common/general/password-dialog.tsx @@ -5,6 +5,9 @@ import { useState } from "react" import { Dialog, DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog" import { InputOTP, @@ -54,13 +57,13 @@ export function PasswordDialog({ return ( + + {title} + + {description} + +
-
-

{title}

-

- {description} -

-
= { community: { label: '社区划转', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' }, online: { label: '在线活动', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' }, distribute: { label: '商户分发', color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300' }, - test: { label: '应用测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' } + test: { label: '应用测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' }, + red_envelope_send: { label: '红包支出', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' }, + red_envelope_receive: { label: '红包收入', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' } } /* 状态标签配置 */ diff --git a/frontend/components/common/redenvelope/red-envelope-claim.tsx b/frontend/components/common/redenvelope/red-envelope-claim.tsx new file mode 100644 index 00000000..99a69e0c --- /dev/null +++ b/frontend/components/common/redenvelope/red-envelope-claim.tsx @@ -0,0 +1,347 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useCallback } from "react" +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 type { RedEnvelopeDetailResponse, RedEnvelopeClaim } from "@/lib/services" + +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 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]) + + 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 + + return ( +
+
+ + {(state === "ready" || state === "opening") && ( + + {/* 红包封面 - WeChat style with enhanced visuals */} + + {/* 红包主体 */} +
+ {/* 顶部金色光晕效果 */} +
+ + {/* 底部阴影 */} +
+ + {/* 装饰性图案 */} +
+
+
+
+
+ + {/* 发送者信息 */} +
+ + + + + {envelope?.creator_username?.charAt(0).toUpperCase()} + + + + + {envelope?.creator_username} 的红包 + +
+ + {/* 中间圆形按钮 - 只有这个可点击 */} +
+ + {/* 内部光晕 */} +
+ + {/* 開 字 */} + + 開 + + + {/* 外部光圈动画 */} + {state === "ready" && ( + + )} + +
+ + {/* 祝福语 */} +
+ + {envelope?.greeting || "恭喜发财,大吉大利"} + +
+
+ + {/* 3D 立体效果阴影 */} +
+ + + {state === "ready" && ( + + 点击 “開” 字领取红包 + + )} + + )} + + {(state === "claimed" || state === "opened") && ( + + + {/* 头部 */} +
+ {/* 背景装饰 */} +
+
+
+
+ + + + + + {envelope?.creator_username?.charAt(0).toUpperCase()} + + + +

{envelope?.creator_username} 的红包

+

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

+
+ + {/* 领取金额 */} + {claimedAmount && ( +
+ +

{claimedAmount}

+

LDC

+
+
+ )} + + {/* 领取记录 */} +
+
+ + {detail?.claims.length || 0}/{envelope?.total_count} 个红包已领取 + + + {envelope?.remaining_amount}/{envelope?.total_amount} LDC + +
+ + +
+ {detail?.claims.map((claim: RedEnvelopeClaim) => ( + +
+ + + + {claim.username.charAt(0).toUpperCase()} + + + {claim.username} +
+ {claim.amount} LDC +
+ ))} +
+
+
+ + {/* 底部按钮 */} +
+ +
+ + + )} + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/components/common/trade/red-envelope.tsx b/frontend/components/common/trade/red-envelope.tsx new file mode 100644 index 00000000..d1eb8555 --- /dev/null +++ b/frontend/components/common/trade/red-envelope.tsx @@ -0,0 +1,655 @@ + +"use client" + +import * as React from "react" +import { useState, useEffect } from "react" +import { toast } from "sonner" +import { Gift, Copy, Check, ExternalLink, Users, Coins } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { Card, CardContent } from "@/components/ui/card" +import { Spinner } from "@/components/ui/spinner" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { PasswordDialog } from "@/components/common/general/password-dialog" +import services from "@/lib/services" +import type { RedEnvelopeType, CreateRedEnvelopeRequest, RedEnvelope, RedEnvelopeListResponse, PublicConfigResponse } from "@/lib/services" +import { formatDateTime } from "@/lib/utils" + +/** + * 红包组件 + * + * 提供红包发送功能和红包列表查看 + */ +export function RedEnvelope() { + const [isFormOpen, setIsFormOpen] = useState(false) + const [isPasswordOpen, setIsPasswordOpen] = useState(false) + const [isResultOpen, setIsResultOpen] = useState(false) + const [copiedId, setCopiedId] = useState(null) + const [activeTab, setActiveTab] = useState<'sent' | 'received'>('sent') + + /* 表单状态 */ + const [type, setType] = useState("random") + const [totalAmount, setTotalAmount] = useState("") + const [totalCount, setTotalCount] = useState("") + const [greeting, setGreeting] = useState("") + const [loading, setLoading] = useState(false) + + /* 结果状态 */ + const [resultLink, setResultLink] = useState("") + + /* 列表状态 */ + const [sentEnvelopes, setSentEnvelopes] = useState([]) + const [receivedEnvelopes, setReceivedEnvelopes] = useState([]) + const [listLoading, setListLoading] = useState(false) + const [sentPage, setSentPage] = useState(1) + const [receivedPage, setReceivedPage] = useState(1) + const [sentTotal, setSentTotal] = useState(0) + const [receivedTotal, setReceivedTotal] = useState(0) + + /* 配置状态 */ + const [config, setConfig] = useState(null) + + /* 加载红包列表 */ + const loadEnvelopes = async (listType: 'sent' | 'received', page: number) => { + setListLoading(true) + try { + const result: RedEnvelopeListResponse = await services.redEnvelope.getList({ + page, + page_size: 10, + type: listType, + }) + + if (listType === 'sent') { + setSentEnvelopes(prev => page === 1 ? result.red_envelopes : [...prev, ...result.red_envelopes]) + setSentTotal(result.total) + } else { + setReceivedEnvelopes(prev => page === 1 ? result.red_envelopes : [...prev, ...result.red_envelopes]) + setReceivedTotal(result.total) + } + } catch { + toast.error('加载红包列表失败') + } finally { + setListLoading(false) + } + } + + /* 加载配置 */ + const loadConfig = async () => { + try { + const result = await services.config.getPublicConfig() + setConfig(result) + } catch { + toast.error('加载配置失败') + } + } + + /* 初始加载 */ + useEffect(() => { + loadConfig() + loadEnvelopes('sent', 1) + loadEnvelopes('received', 1) + }, []) + + /* 标签切换时加载数据 */ + useEffect(() => { + if (activeTab === 'sent' && sentEnvelopes.length === 0) { + loadEnvelopes('sent', 1) + } else if (activeTab === 'received' && receivedEnvelopes.length === 0) { + loadEnvelopes('received', 1) + } + }, [activeTab, sentEnvelopes.length, receivedEnvelopes.length]) + + /* 验证金额格式 */ + const validateAmount = (value: string): boolean => { + const regex = /^\d+(\.\d{1,2})?$/ + return regex.test(value) && parseFloat(value) > 0 + } + + /* 处理表单提交(第一步) */ + const handleFormSubmit = () => { + if (!totalAmount.trim()) { + toast.error("请输入红包总金额") + return + } + + if (!validateAmount(totalAmount)) { + toast.error("金额格式不正确,必须大于0且最多2位小数") + return + } + + if (!totalCount.trim()) { + toast.error("请输入红包个数") + return + } + + const count = parseInt(totalCount) + if (isNaN(count) || count <= 0) { + toast.error("红包个数必须大于0") + return + } + + const amount = parseFloat(totalAmount) + + // 检查红包最大金额限制 + 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`) + return + } + } + + if (type === "fixed" && amount / count < 0.01) { + toast.error("每个红包金额不能小于0.01") + return + } + + if (greeting.length > 100) { + toast.error("祝福语最多100字符") + return + } + + setIsFormOpen(false) + setIsPasswordOpen(true) + } + + /* 处理最终创建(第二步) */ + const handleConfirmCreate = async (password: string) => { + setLoading(true) + try { + const data: CreateRedEnvelopeRequest = { + type, + total_amount: parseFloat(totalAmount), + total_count: parseInt(totalCount), + greeting: greeting || "恭喜发财,大吉大利", + pay_key: password, + } + + const result = await services.redEnvelope.create(data) + + // 前端生成链接 + const link = getEnvelopeLink(result.id) + setResultLink(link) + setIsPasswordOpen(false) + setIsResultOpen(true) + + /* 重置表单 */ + setType("random") + setTotalAmount("") + setTotalCount("") + setGreeting("") + + /* 刷新发送列表 */ + loadEnvelopes('sent', 1) + setSentPage(1) + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : '创建失败' + toast.error('创建红包失败', { description: errorMessage }) + } finally { + setLoading(false) + } + } + + /* 复制链接 */ + const handleCopyLink = async (link: string, envelopeId: string) => { + try { + await navigator.clipboard.writeText(link) + setCopiedId(envelopeId) + toast.success("链接已复制") + setTimeout(() => setCopiedId(null), 2000) + } catch { + toast.error("复制失败") + } + } + + /* 计算每个红包金额 */ + const perAmount = React.useMemo(() => { + if (!totalAmount || !totalCount) return null + const amount = parseFloat(totalAmount) + const count = parseInt(totalCount) + if (isNaN(amount) || isNaN(count) || count <= 0) return null + if (type === "fixed") { + return (amount / count).toFixed(2) + } + return null + }, [totalAmount, totalCount, type]) + + /* 计算手续费 */ + const feeInfo = React.useMemo(() => { + if (!totalAmount || !config) return null + const amount = parseFloat(totalAmount) + if (isNaN(amount)) return null + + const feeRate = parseFloat(config.red_envelope_fee_rate) + const fee = (amount * feeRate).toFixed(2) + const total = (amount + parseFloat(fee)).toFixed(2) + + return { fee, total, rate: feeRate } + }, [totalAmount, config]) + + /* 获取状态标签 */ + const getStatusBadge = (status: string) => { + const config = { + active: { label: '进行中', color: 'bg-green-500/10 text-green-600 border-green-500/20' }, + finished: { label: '已领完', color: 'bg-gray-500/10 text-gray-600 border-gray-500/20' }, + expired: { label: '已过期', color: 'bg-red-500/10 text-red-600 border-red-500/20' }, + }[status] || { label: status, color: '' } + + return ( + + {config.label} + + ) + } + + /* 生成红包链接 */ + const getEnvelopeLink = (id: string) => { + if (typeof window !== 'undefined') { + return `${window.location.origin}/redenvelope/${id}` + } + return `/redenvelope/${id}` + } + + /* 渲染红包卡片 */ + const renderEnvelopeCard = (envelope: RedEnvelope, isSent: boolean) => { + const link = getEnvelopeLink(envelope.id) + const claimedCount = envelope.total_count - envelope.remaining_count + const claimedAmount = (parseFloat(envelope.total_amount) - parseFloat(envelope.remaining_amount)).toFixed(2) + + return ( + + +
+
+
+ +
+
+
+ {isSent ? '发给朋友' : `来自 ${envelope.creator_username}`} +
+
+ {envelope.greeting || "恭喜发财,大吉大利"} +
+
+
+ {getStatusBadge(envelope.status)} +
+ +
+
+
总金额
+
+ + {envelope.total_amount} LDC +
+
+
+
已领取
+
+ + {claimedCount}/{envelope.total_count} 个 +
+
+
+
剩余金额
+
+ {envelope.remaining_amount} LDC +
+
+
+
已领金额
+
+ {claimedAmount} LDC +
+
+
+ +
+ 类型: {envelope.type === 'random' ? '拼手气' : '固定金额'} + + {formatDateTime(envelope.created_at)} +
+ + {isSent && ( +
+ + +
+ )} + + {!isSent && envelope.status === 'active' && ( + + )} +
+
+ ) + } + + return ( +
+
+
+

+ + 发红包 +

+

+ 创建红包分享给好友,支持固定金额和拼手气两种模式。 +

+ +
+
+ + {/* 红包列表 */} +
+ setActiveTab(v as 'sent' | 'received')}> + + + + 我发出的 ({sentTotal}) + + + + 我收到的 ({receivedTotal}) + + + + + {listLoading && sentEnvelopes.length === 0 ? ( +
+ +
+ ) : sentEnvelopes.length === 0 ? ( +
+ +

还没有发送红包记录

+
+ ) : ( + <> +
+ {sentEnvelopes.map(envelope => renderEnvelopeCard(envelope, true))} +
+ {sentEnvelopes.length < sentTotal && ( + + )} + + )} +
+ + + {listLoading && receivedEnvelopes.length === 0 ? ( +
+ +
+ ) : receivedEnvelopes.length === 0 ? ( +
+ +

还没有收到红包记录

+
+ ) : ( + <> +
+ {receivedEnvelopes.map(envelope => renderEnvelopeCard(envelope, false))} +
+ {receivedEnvelopes.length < receivedTotal && ( + + )} + + )} +
+
+
+ + {/* 创建红包表单 */} + + + + + + 发红包 + + + 创建红包并分享链接给好友领取 + + + +
+
+ + +

+ {type === "random" ? "每人领取金额随机" : "每人领取相同金额"} +

+
+ +
+
+ +
+ LDC + setTotalAmount(e.target.value)} + className="pl-12 font-mono" + disabled={loading} + /> +
+ {config && parseFloat(config.red_envelope_max_amount) > 0 ? ( +

+ 最大 {config.red_envelope_max_amount} LDC +

+ ) : ( +

6565

+ )} +
+ +
+ + setTotalCount(e.target.value)} + disabled={loading} + /> +

占位

+
+
+ + {type === "fixed" && perAmount && ( +

+ 每个红包 {perAmount} LDC +

+ )} + + {feeInfo && parseFloat(feeInfo.fee) > 0 && ( +
+
+
+ 红包金额 + {totalAmount} LDC +
+
+ 手续费 ({(feeInfo.rate * 100).toFixed(1)}%) + +{feeInfo.fee} LDC +
+
+ 总计支付 + {feeInfo.total} LDC +
+
+
+ )} + +
+ +