From 5d36e879393153df73dacdc9d3cc8f69b774126a Mon Sep 17 00:00:00 2001 From: cattie <2237829695@qq.com> Date: Mon, 29 Dec 2025 15:22:17 +0800 Subject: [PATCH 1/6] feat: Red Envelope function --- config.example.yaml | 1 + .../app/(pay)/redenvelope/[code]/page.tsx | 10 + frontend/app/globals.css | 25 +- .../common/general/table-filter.tsx | 4 +- .../common/redenvelope/red-envelope-claim.tsx | 347 +++++++++++ .../components/common/trade/red-envelope.tsx | 586 ++++++++++++++++++ .../components/common/trade/trade-main.tsx | 20 +- frontend/lib/services/index.ts | 18 + frontend/lib/services/redenvelope/index.ts | 14 + .../redenvelope/redenvelope.service.ts | 113 ++++ frontend/lib/services/redenvelope/types.ts | 160 +++++ frontend/lib/services/transaction/types.ts | 2 +- internal/apps/order/routers.go | 63 +- internal/apps/redenvelope/errs.go | 29 + internal/apps/redenvelope/routers.go | 506 +++++++++++++++ internal/apps/redenvelope/tasks.go | 109 ++++ internal/config/model.go | 2 + internal/db/migrator/migrator.go | 7 + internal/model/orders.go | 2 + internal/model/red_envelopes.go | 76 +++ internal/model/system_configs.go | 11 + internal/router/router.go | 11 + internal/task/constants.go | 1 + internal/task/scheduler/scheduler.go | 9 + internal/task/worker/worker.go | 2 + 25 files changed, 2095 insertions(+), 33 deletions(-) create mode 100644 frontend/app/(pay)/redenvelope/[code]/page.tsx create mode 100644 frontend/components/common/redenvelope/red-envelope-claim.tsx create mode 100644 frontend/components/common/trade/red-envelope.tsx create mode 100644 frontend/lib/services/redenvelope/index.ts create mode 100644 frontend/lib/services/redenvelope/redenvelope.service.ts create mode 100644 frontend/lib/services/redenvelope/types.ts create mode 100644 internal/apps/redenvelope/errs.go create mode 100644 internal/apps/redenvelope/routers.go create mode 100644 internal/apps/redenvelope/tasks.go create mode 100644 internal/model/red_envelopes.go diff --git a/config.example.yaml b/config.example.yaml index af36a527..6baeda19 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(优先) diff --git a/frontend/app/(pay)/redenvelope/[code]/page.tsx b/frontend/app/(pay)/redenvelope/[code]/page.tsx new file mode 100644 index 00000000..3f71451f --- /dev/null +++ b/frontend/app/(pay)/redenvelope/[code]/page.tsx @@ -0,0 +1,10 @@ +import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim" + +interface Props { + params: Promise<{ code: string }> +} + +export default async function RedEnvelopePage({ params }: Props) { + const { code } = 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/table-filter.tsx b/frontend/components/common/general/table-filter.tsx index e45f38df..8b3d0de2 100644 --- a/frontend/components/common/general/table-filter.tsx +++ b/frontend/components/common/general/table-filter.tsx @@ -24,7 +24,9 @@ export const typeConfig: Record = { transfer: { label: '积分转移', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' }, 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' }, - 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..32683284 --- /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 } 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 { + code: string +} + +type ClaimState = "loading" | "ready" | "opening" | "opened" | "claimed" | "error" + +export function RedEnvelopeClaimPage({ code }: RedEnvelopeClaimProps) { + const [state, setState] = useState("loading") + const [detail, setDetail] = useState(null) + const [claimedAmount, setClaimedAmount] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + loadDetail() + }, [code]) + + const loadDetail = async () => { + try { + const data = await services.redEnvelope.getDetail(code) + 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") + } + } + + const handleOpen = async () => { + setState("opening") + try { + const result = await services.redEnvelope.claim({ code }) + setClaimedAmount(result.amount) + + // Reload the full details to get updated claims list + const updatedDetail = await services.redEnvelope.getDetail(code) + 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..4f088eee --- /dev/null +++ b/frontend/components/common/trade/red-envelope.tsx @@ -0,0 +1,586 @@ + +"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 } 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 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 (error) { + toast.error('加载红包列表失败') + } finally { + setListLoading(false) + } + } + + /* 初始加载 */ + useEffect(() => { + 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]) + + /* 验证金额格式 */ + 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 (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 || undefined, + pay_key: password, + } + + const result = await services.redEnvelope.create(data) + + setResultLink(result.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: number) => { + 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 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 = (code: string) => { + if (typeof window !== 'undefined') { + return `${window.location.origin}/redenvelope/${code}` + } + return `/redenvelope/${code}` + } + + /* 渲染红包卡片 */ + const renderEnvelopeCard = (envelope: RedEnvelope, isSent: boolean) => { + const link = getEnvelopeLink(envelope.code) + 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} + /> +
+
+ +
+ + setTotalCount(e.target.value)} + disabled={loading} + /> +
+
+ + {type === "fixed" && perAmount && ( +

+ 每个红包 {perAmount} LDC +

+ )} + +
+ +