mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import * as React from "react"
|
||||
import { TrendingDown, TrendingUp, Users } from "lucide-react"
|
||||
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
|
||||
/**
|
||||
* 安全转换数字,避免 NaN
|
||||
*/
|
||||
function safeNumber(value: number): number {
|
||||
return typeof value === 'number' && !isNaN(value) ? value : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有活动组件
|
||||
* 显示用户的所有活动统计数据
|
||||
*/
|
||||
export function AllActivity() {
|
||||
const { user, loading } = useUser()
|
||||
|
||||
/* 统计数据 */
|
||||
const stats = React.useMemo(() => ({
|
||||
totalReceive: parseFloat(user?.total_receive || '0'),
|
||||
totalPayment: parseFloat(user?.total_payment || '0'),
|
||||
totalCommunity: parseFloat(user?.total_community || '0')
|
||||
}), [user])
|
||||
|
||||
/* 功能卡片配置 */
|
||||
const statCards = React.useMemo(() => [
|
||||
{
|
||||
title: "总收益",
|
||||
value: stats.totalReceive,
|
||||
icon: TrendingUp,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-100 dark:bg-green-900/20"
|
||||
},
|
||||
{
|
||||
title: "总消耗",
|
||||
value: stats.totalPayment,
|
||||
icon: TrendingDown,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-100 dark:bg-red-900/20"
|
||||
},
|
||||
{
|
||||
title: "总划转",
|
||||
value: stats.totalCommunity,
|
||||
icon: Users,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-100 dark:bg-purple-900/20"
|
||||
}
|
||||
], [stats])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold">数据概览</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{statCards.map((card) => {
|
||||
const IconComponent = card.icon
|
||||
return (
|
||||
<div key={card.title} className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className={`p-1.5 rounded-full ${ card.bgColor }`}>
|
||||
<IconComponent className={`h-3 w-3 ${ card.color }`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl md:text-2xl font-bold mt-2">
|
||||
{loading ? '-' : (
|
||||
<CountingNumber
|
||||
number={safeNumber(card.value)}
|
||||
decimalPlaces={2}
|
||||
initiallyStable={true}
|
||||
inView={true}
|
||||
inViewOnce={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* 社区积分划转组件
|
||||
*
|
||||
* 显示社区积分划转规则
|
||||
*/
|
||||
export function Community() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg px-6 py-4">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="font-semibold mb-3">LINUX DO 社区积分划转规则</h2>
|
||||
<div className="text-muted-foreground space-y-2">
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
<li>社区积分源于用户在社区内的活动,如解决问题,获赞等。</li>
|
||||
<li>社区积分无法在每日划转前进行交易、赠予给其他社区用户。</li>
|
||||
<li>每日划转时间为每日凌晨00:00:00,目前划转无需任何服务手续费。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
/**
|
||||
* 积分流转服务组件
|
||||
*
|
||||
* 显示积分流转服务功能和管理入口
|
||||
*/
|
||||
export function Online() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg px-6 py-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">在线积分流转</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
创建和管理您的在线积分流转服务,无需编写代码即可快速开启个人服务。
|
||||
</p>
|
||||
<Link href="/merchant/online-paying">
|
||||
<Button className="bg-primary hover:bg-primary/90 font-medium px-6 rounded-md shadow-sm">
|
||||
前往创建
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* 积分消耗组件
|
||||
*
|
||||
* 显示积分消耗功能和规则
|
||||
*/
|
||||
export function Payment() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg px-6 py-4">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="font-semibold mb-3">积分消耗</h2>
|
||||
<div className="text-muted-foreground space-y-2">
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
<li>安全可靠的认证流程,确保您的账户安全。</li>
|
||||
<li>实时积分消耗状态跟踪,随时了解消耗进展。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
/**
|
||||
* 功能卡片组件
|
||||
*/
|
||||
function FeatureCard({ title, description, linkText, href }: { title: string, description: string, linkText: string, href?: string }) {
|
||||
return (
|
||||
<Card className="bg-background border border-border shadow-none transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold -mb-4">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-xs text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</CardDescription>
|
||||
{href ? (
|
||||
<Link href={href}>
|
||||
<Button variant="link" className="px-0 h-auto text-xs text-blue-600 font-normal hover:text-blue-700">
|
||||
{linkText}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="link" className="px-0 h-auto text-xs text-muted-foreground font-normal cursor-not-allowed" disabled>
|
||||
{linkText}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分收益组件
|
||||
*
|
||||
* 显示积分收益功能和规则
|
||||
*/
|
||||
export function Receive() {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [isHiding, setIsHiding] = useState(false)
|
||||
|
||||
const handleHide = () => {
|
||||
setIsHiding(true)
|
||||
setTimeout(() => setIsVisible(false), 300)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isVisible && (
|
||||
<div
|
||||
className={`bg-muted/50 rounded-lg px-6 py-8 overflow-hidden transition-all duration-300 ease-out ${isHiding ? 'opacity-0 max-h-0 !py-0 !mb-0' : 'opacity-100 max-h-[500px]'}`}
|
||||
>
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">获得积分收益</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
通过无代码选项快速开始探索或使用与我们的 API 集成的可自定义积分服务。
|
||||
</p>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 font-medium px-6 rounded-md shadow-sm"
|
||||
onClick={handleHide}
|
||||
>
|
||||
开始使用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<FeatureCard
|
||||
title="接入官方 API 接口"
|
||||
description="使用官方 API 接口(兼容易支付),快速接入积分服务,支持异步通知和同步跳转。"
|
||||
linkText="查看文档"
|
||||
href="/docs/api"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="创建在线积分服务"
|
||||
description="通过我们提供的在线积分服务页面,快速创建,无需编写代码即可开始进行积分流转。"
|
||||
linkText="前往集市"
|
||||
href="/merchant"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="面对面服务"
|
||||
description="通过 LINUX DO Credit 提供的面对面服务功能,扩展您的积分收益渠道,处理面对面服务场景。"
|
||||
linkText="功能开发中,敬请期待"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import { toast } from "sonner"
|
||||
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"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
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 { 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<string | null>(null)
|
||||
const [isCropperOpen, setIsCropperOpen] = useState(false)
|
||||
const [cropperType, setCropperType] = useState<'cover' | 'heterotypic'>('cover')
|
||||
const [showCustomization, setShowCustomization] = useState(false)
|
||||
const [historyCoverCovers, setHistoryCoverCovers] = useState<UploadImageResponse[]>([])
|
||||
const [historyHeterotypicCovers, setHistoryHeterotypicCovers] = useState<UploadImageResponse[]>([])
|
||||
|
||||
/* 表单状态 */
|
||||
const [type, setType] = useState<RedEnvelopeType>("random")
|
||||
const [totalAmount, setTotalAmount] = useState("")
|
||||
const [totalCount, setTotalCount] = useState("")
|
||||
const [greeting, setGreeting] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/* 封面状态 */
|
||||
const [cover, setCover] = useState<RedEnvelopeCover>({
|
||||
coverImageUrl: null,
|
||||
heterotypicImageUrl: null,
|
||||
coverUploadId: null,
|
||||
heterotypicUploadId: null
|
||||
})
|
||||
|
||||
/* 结果状态 */
|
||||
const [resultLink, setResultLink] = useState("")
|
||||
|
||||
/* 配置状态 */
|
||||
const [config, setConfig] = useState<PublicConfigResponse | null>(null)
|
||||
|
||||
/* 加载配置 */
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const result = await services.config.getPublicConfig()
|
||||
setConfig(result)
|
||||
} catch {
|
||||
toast.error('加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
/* 初始加载 */
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
/* 验证积分格式 */
|
||||
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("积分格式不正确,必须大于1且最多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 (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`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 10000) {
|
||||
toast.error("可领取人数不能超过 10000 个")
|
||||
return
|
||||
}
|
||||
|
||||
if (amount / count < 0.01) {
|
||||
toast.error("单个红包不可低于 0.01 LDC")
|
||||
return
|
||||
}
|
||||
|
||||
if (greeting.length > 100) {
|
||||
toast.error("祝福语最多100字符")
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
cover_upload_id: cover.coverUploadId || undefined,
|
||||
heterotypic_upload_id: cover.heterotypicUploadId || undefined,
|
||||
}
|
||||
|
||||
const result = await services.redEnvelope.create(data)
|
||||
|
||||
const link = getEnvelopeLink(result.id)
|
||||
setResultLink(link)
|
||||
setIsPasswordOpen(false)
|
||||
setIsFormOpen(false)
|
||||
setIsResultOpen(true)
|
||||
onSuccess?.()
|
||||
|
||||
/* 重置表单 */
|
||||
setType("random")
|
||||
setTotalAmount("")
|
||||
setTotalCount("")
|
||||
setGreeting("")
|
||||
setCover({
|
||||
coverImageUrl: null,
|
||||
heterotypicImageUrl: null,
|
||||
coverUploadId: null,
|
||||
heterotypicUploadId: null
|
||||
})
|
||||
setShowCustomization(false)
|
||||
|
||||
} 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 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 getEnvelopeLink = (id: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${ window.location.origin }/redenvelope/${ id }`
|
||||
}
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gradient-to-r from-red-500/10 to-orange-500/10 rounded-lg px-6 py-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">
|
||||
发红包
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
创建积分红包,支持固定金额和拼手气两种模式。
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="bg-red-500 hover:bg-red-600 font-medium px-6 rounded-md shadow-sm"
|
||||
>
|
||||
发红包
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
||||
<DialogContent className="sm:max-w-2xl md:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift className="size-5 text-red-500" />
|
||||
发红包
|
||||
</div>
|
||||
<Button
|
||||
variant={showCustomization ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setShowCustomization(!showCustomization)}
|
||||
className={`flex items-center gap-1.5 text-xs transition-colors ${ showCustomization ? "bg-red-500 hover:bg-red-600 text-white" : "text-muted-foreground hover:text-foreground" }`}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
<span>自定义封面</span>
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建积分红包,支持固定金额和拼手气两种模式{showCustomization && ",可添加个性化封面"}。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid md:grid-cols-5 gap-8 pt-2">
|
||||
<div className="space-y-4 md:col-span-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>红包类型 <span className="text-red-500">*</span></Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as RedEnvelopeType)}>
|
||||
<SelectTrigger className="text-xs w-full shadow-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="random" className="text-xs">拼手气红包</SelectItem>
|
||||
<SelectItem value="fixed" className="text-xs">普通红包</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalAmount">积分总数 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
id="totalAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={config && parseFloat(config.red_envelope_max_amount) > 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalCount">可领取人数 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
id="totalCount"
|
||||
type="number"
|
||||
placeholder="最多 10000"
|
||||
value={totalCount}
|
||||
onChange={(e) => setTotalCount(e.target.value)}
|
||||
className="font-mono shadow-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="greeting">祝福语</Label>
|
||||
<Textarea
|
||||
id="greeting"
|
||||
placeholder="恭喜发财,大吉大利"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
maxLength={100}
|
||||
rows={2}
|
||||
className="resize-none shadow-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCustomization && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="rounded-lg bg-muted/40 p-4"
|
||||
onAnimationComplete={() => loadHistoryCovers()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-sm font-medium">红包封面</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setShowCustomization(false)
|
||||
setCover({
|
||||
coverImageUrl: null,
|
||||
heterotypicImageUrl: null,
|
||||
coverUploadId: null,
|
||||
heterotypicUploadId: null
|
||||
})
|
||||
}}
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">背景封面</Label>
|
||||
{cover.coverImageUrl ? (
|
||||
<div className="relative group rounded-lg overflow-hidden ring-1 ring-border/50 aspect-[2/3] w-full">
|
||||
<Image
|
||||
src={cover.coverImageUrl}
|
||||
alt="背景封面"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex items-center justify-center gap-1.5 opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
|
||||
onClick={() => handleOpenCropper('cover')}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
|
||||
onClick={() => handleRemoveCover('cover')}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleOpenCropper('cover')}
|
||||
className="w-full aspect-[2/3] rounded-lg border border-dashed border-border hover:border-foreground/25 bg-background hover:bg-muted/50 transition-colors flex flex-col items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<ImagePlus className="size-5 text-muted-foreground/50" />
|
||||
<span className="text-[11px] text-muted-foreground">上传背景</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">装饰图片</Label>
|
||||
{cover.heterotypicImageUrl ? (
|
||||
<div className="relative group rounded-lg overflow-hidden ring-1 ring-border/50 aspect-[2/3] w-full">
|
||||
<Image
|
||||
src={cover.heterotypicImageUrl}
|
||||
alt="装饰图片"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex items-center justify-center gap-1.5 opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
|
||||
onClick={() => handleOpenCropper('heterotypic')}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
|
||||
onClick={() => handleRemoveCover('heterotypic')}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleOpenCropper('heterotypic')}
|
||||
className="w-full aspect-[2/3] rounded-lg border border-dashed border-border hover:border-foreground/25 bg-background hover:bg-muted/50 transition-colors flex flex-col items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<ImagePlus className="size-5 text-muted-foreground/50" />
|
||||
<span className="text-[11px] text-muted-foreground">上传装饰</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-2.5 text-center">推荐 2:3 比例,上传后可裁剪</p>
|
||||
|
||||
{(historyCoverCovers.length > 0 || historyHeterotypicCovers.length > 0) && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<History className="size-3 text-muted-foreground/60" />
|
||||
<span className="text-[11px] text-muted-foreground/60">历史封面</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
{historyCoverCovers.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto p-1 no-scrollbar">
|
||||
{historyCoverCovers.map((item: UploadImageResponse) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectHistoryCover(item, 'cover')}
|
||||
className={`relative shrink-0 size-12 rounded-md overflow-hidden ring-1 transition-all ${ cover.coverUploadId === item.id
|
||||
? 'ring-2 ring-primary scale-105'
|
||||
: 'ring-border/50 hover:ring-foreground/25'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={getFileUrl(item.id) || ''}
|
||||
alt="背景"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
onError={() => handleImageError(item.id, 'cover')}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{historyHeterotypicCovers.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto p-1 no-scrollbar">
|
||||
{historyHeterotypicCovers.map((item: UploadImageResponse) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectHistoryCover(item, 'heterotypic')}
|
||||
className={`relative shrink-0 size-12 rounded-md overflow-hidden ring-1 transition-all ${ cover.heterotypicUploadId === item.id
|
||||
? 'ring-2 ring-primary scale-105'
|
||||
: 'ring-border/50 hover:ring-foreground/25'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={getFileUrl(item.id) || ''}
|
||||
alt="装饰"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
onError={() => handleImageError(item.id, 'heterotypic')}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{feeInfo && parseFloat(feeInfo.fee) > 0 && (
|
||||
<div className="text-xs space-y-1.5 p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">积分</span>
|
||||
<span className="font-mono text-muted-foreground">{totalAmount} LDC</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">
|
||||
费率 ({(feeInfo.rate * 100).toFixed(2)}%)
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">+{feeInfo.fee} LDC</span>
|
||||
</div>
|
||||
<div className="border-t border-border/50 my-0.5" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-foreground font-medium">总计</span>
|
||||
<span className="font-mono font-semibold text-red-500">{feeInfo.total} LDC</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-center md:justify-end md:pr-8 my-6 md:my-0">
|
||||
<div className="relative w-52 h-80">
|
||||
{cover.heterotypicImageUrl && (
|
||||
<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"
|
||||
>
|
||||
<Image
|
||||
src={cover.heterotypicImageUrl}
|
||||
alt="装饰"
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{
|
||||
transform: 'scale(1.2)',
|
||||
transformOrigin: 'center center'
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 缩放容器:将 320px 宽的卡片等比缩放以适应 208px (w-52) 的容器 */}
|
||||
<div className="absolute inset-0 overflow-hidden bg-[#F25542] rounded-lg">
|
||||
<div className="absolute top-0 left-0 origin-top-left" style={{ width: 320, height: 533, transform: 'scale(0.65)' }}>
|
||||
<RedEnvelopeCard
|
||||
status="preview"
|
||||
coverImage={cover.coverImageUrl}
|
||||
greeting={greeting || "新年快乐,恭喜发财"}
|
||||
sender={{
|
||||
username: user?.username,
|
||||
avatar_url: user?.avatar_url
|
||||
}}
|
||||
className="rounded-xl w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="ghost" onClick={() => setIsFormOpen(false)} disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFormSubmit}
|
||||
disabled={!totalAmount || !totalCount || loading || !!(config && config.red_envelope_max_recipients > 0 && parseInt(totalCount) > config.red_envelope_max_recipients)}
|
||||
className="bg-red-500 hover:bg-red-600 h-8 text-xs"
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PasswordDialog
|
||||
isOpen={isPasswordOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsPasswordOpen(open)
|
||||
if (!open) setIsFormOpen(true)
|
||||
}}
|
||||
onConfirm={handleConfirmCreate}
|
||||
loading={loading}
|
||||
title="密码验证"
|
||||
description={
|
||||
feeInfo && parseFloat(feeInfo.fee) > 0
|
||||
? `正在创建 ${ totalAmount } LDC 的积分红包,可领取人数 ${ totalCount } 个,服务费率 ${ feeInfo.fee } LDC,总计需要 ${ feeInfo.total } LDC`
|
||||
: `正在创建 ${ totalAmount } LDC 的积分红包,可领取人数 ${ totalCount } 个`
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog open={isResultOpen} onOpenChange={setIsResultOpen}>
|
||||
<DialogContent className="w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||
创建成功
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
复制此链接分享给好友领取红包
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={resultLink}
|
||||
readOnly
|
||||
className="text-xs font-mono shadow-none border-none bg-muted"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleCopyLink(resultLink, "result")}
|
||||
>
|
||||
{copiedId === "result" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(resultLink, "_blank")}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setIsResultOpen(false)} className="h-8 text-xs">
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ImageCropper
|
||||
isOpen={isCropperOpen}
|
||||
onOpenChange={setIsCropperOpen}
|
||||
onCropComplete={handleCropComplete}
|
||||
coverType={cropperType}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 红包列表组件
|
||||
*
|
||||
* 使用通用 ListData 组件展示红包记录
|
||||
*/
|
||||
export function RedEnvelopeList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
const fetchEnvelopes = async (params: { page: number, page_size: number, type: string }) => {
|
||||
const result: RedEnvelopeListResponse = await services.redEnvelope.getList({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
type: params.type as 'sent' | 'received',
|
||||
})
|
||||
return { list: result.red_envelopes, total: result.total }
|
||||
}
|
||||
|
||||
return (
|
||||
<ListData
|
||||
fetchData={fetchEnvelopes}
|
||||
tabs={[
|
||||
{ value: 'sent', label: '我发出的', color: 'bg-primary/10 text-primary' },
|
||||
{ value: 'received', label: '我收到的', color: 'bg-primary/10 text-primary' }
|
||||
]}
|
||||
defaultTab="received"
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { AllActivity } from "@/components/common/trade/all-activity"
|
||||
import { Community } from "@/components/common/trade/community"
|
||||
import { Payment } from "@/components/common/trade/payment"
|
||||
import { Receive } from "@/components/common/trade/receive"
|
||||
import { TradeTable } from "@/components/common/trade/trade-table"
|
||||
import { Online } from "@/components/common/trade/online"
|
||||
import { RedEnvelope, RedEnvelopeList } from "@/components/common/trade/red-envelope"
|
||||
import { TransactionProvider } from "@/contexts/transaction-context"
|
||||
import services from "@/lib/services"
|
||||
|
||||
/** 标签触发器样式 */
|
||||
const TAB_TRIGGER_STYLES =
|
||||
"data-[state=active]:bg-transparent " +
|
||||
"data-[state=active]:shadow-none " +
|
||||
"data-[state=active]:border-0 " +
|
||||
"data-[state=active]:border-b-2 " +
|
||||
"data-[state=active]:border-indigo-500 " +
|
||||
"bg-transparent " +
|
||||
"rounded-none " +
|
||||
"border-0 " +
|
||||
"border-b-2 " +
|
||||
"border-transparent " +
|
||||
"px-0 " +
|
||||
"text-sm " +
|
||||
"font-bold " +
|
||||
"text-muted-foreground " +
|
||||
"data-[state=active]:text-indigo-500 " +
|
||||
"-mb-[2px] " +
|
||||
"relative " +
|
||||
"hover:text-foreground " +
|
||||
"transition-colors " +
|
||||
"flex-none"
|
||||
|
||||
/** 标签值类型 */
|
||||
type TabValue = 'receive' | 'payment' | 'community' | 'online' | 'all' | 'redenvelope'
|
||||
|
||||
/** 页面组件映射表 */
|
||||
const PAGE_COMPONENTS: Record<TabValue, React.ComponentType> = {
|
||||
receive: Receive,
|
||||
payment: Payment,
|
||||
community: Community,
|
||||
online: Online,
|
||||
redenvelope: RedEnvelope,
|
||||
all: AllActivity,
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动交易主页面组件
|
||||
*
|
||||
* 负责组装活动交易中心的各个子组件
|
||||
*/
|
||||
export function TradeMain() {
|
||||
const [activeTab, setActiveTab] = React.useState<TabValue>('receive')
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [refreshKey, setRefreshKey] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const [redEnvelopeEnabled, setRedEnvelopeEnabled] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
/* 获取红包状态 */
|
||||
services.config.getPublicConfig()
|
||||
.then(res => setRedEnvelopeEnabled(res.red_envelope_enabled))
|
||||
.catch(() => setRedEnvelopeEnabled(false))
|
||||
}, [])
|
||||
|
||||
/* 获取活动类型 */
|
||||
const getOrderType = (tab: TabValue) => {
|
||||
if (tab === 'all' || tab === 'redenvelope') return undefined
|
||||
return tab
|
||||
}
|
||||
|
||||
/* 渲染活动页面内容 */
|
||||
const renderPageContent = () => {
|
||||
if (activeTab === 'redenvelope') {
|
||||
return <RedEnvelope onSuccess={() => setRefreshKey(prev => prev + 1)} />
|
||||
}
|
||||
const Component = PAGE_COMPONENTS[activeTab]
|
||||
return Component ? <Component /> : null
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">积分活动</h1>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-10 bg-muted rounded" />
|
||||
<div className="h-64 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TransactionProvider defaultParams={{ page_size: 20 }}>
|
||||
<div className="py-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">积分活动</h1>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="receive" className={TAB_TRIGGER_STYLES}>积分收益</TabsTrigger>
|
||||
<TabsTrigger value="payment" className={TAB_TRIGGER_STYLES}>积分消耗</TabsTrigger>
|
||||
<TabsTrigger value="community" className={TAB_TRIGGER_STYLES}>社区划转</TabsTrigger>
|
||||
<TabsTrigger value="online" className={TAB_TRIGGER_STYLES}>在线流转</TabsTrigger>
|
||||
{redEnvelopeEnabled && (
|
||||
<TabsTrigger value="redenvelope" className={TAB_TRIGGER_STYLES}>积分红包</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="all" className={TAB_TRIGGER_STYLES}>所有活动</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="pt-2 space-y-8">
|
||||
{renderPageContent()}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold">活动记录</h2>
|
||||
{activeTab === 'redenvelope' ? (
|
||||
<RedEnvelopeList refreshTrigger={refreshKey} />
|
||||
) : (
|
||||
<TradeTable key={activeTab} type={getOrderType(activeTab)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</TransactionProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as React from "react"
|
||||
import { TransactionTableList } from "@/components/common/general/table-data"
|
||||
import { TableFilter, mapDisplayStatusToQuery, type DisplayOrderStatus, type SearchValues } from "@/components/common/general/table-filter"
|
||||
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
|
||||
import { DEFAULT_ORDER_TYPES, type OrderType, type TransactionQueryParams } from "@/lib/services"
|
||||
import { formatLocalDate } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* 获取默认日期范围(最近30天)
|
||||
*/
|
||||
function getDefaultDateRange() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 29)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动表格组件
|
||||
*
|
||||
* 支持类型、状态、时间范围筛选的活动记录显示(支持分页)
|
||||
*/
|
||||
export function TradeTable({ type }: { type?: OrderType }) {
|
||||
/* 获取时间范围 */
|
||||
const { from, to: tomorrow } = getDefaultDateRange()
|
||||
const startTime = formatLocalDate(from)
|
||||
const endTime = formatLocalDate(tomorrow)
|
||||
|
||||
return (
|
||||
<TransactionProvider defaultParams={{ page_size: 20, startTime, endTime }}>
|
||||
<TransactionList initialType={type} />
|
||||
</TransactionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动列表组件
|
||||
*
|
||||
* 显示交用户活动记录
|
||||
*/
|
||||
function TransactionList({ initialType }: { initialType?: OrderType }) {
|
||||
const {
|
||||
transactions,
|
||||
total,
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageSize,
|
||||
loading,
|
||||
error,
|
||||
fetchTransactions,
|
||||
goToPage,
|
||||
setPageSize: setPageSizeHandler,
|
||||
clearCacheAndRefresh,
|
||||
} = useTransaction()
|
||||
|
||||
const [selectedTypes, setSelectedTypes] = React.useState<OrderType[]>(initialType ? [initialType] : [])
|
||||
const [selectedStatus, setSelectedStatus] = React.useState<DisplayOrderStatus | null>(null)
|
||||
const [selectedQuickSelection, setSelectedQuickSelection] = React.useState<string | null>("最近 1 个月")
|
||||
const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange)
|
||||
const [selectedSearch, setSelectedSearch] = React.useState<SearchValues>({})
|
||||
|
||||
const buildQueryParams = React.useCallback((page: number): TransactionQueryParams => {
|
||||
const stateQuery = mapDisplayStatusToQuery(selectedStatus)
|
||||
|
||||
return {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
types: selectedTypes.length > 0 ? selectedTypes : DEFAULT_ORDER_TYPES,
|
||||
...stateQuery,
|
||||
startTime: dateRange ? formatLocalDate(dateRange.from) : undefined,
|
||||
endTime: dateRange ? (() => {
|
||||
const endDate = new Date(dateRange.to)
|
||||
endDate.setDate(endDate.getDate() + 1)
|
||||
return formatLocalDate(endDate)
|
||||
})() : undefined,
|
||||
id: selectedSearch.id || undefined,
|
||||
order_name: selectedSearch.order_name || undefined,
|
||||
payer_username: selectedSearch.payer_username || undefined,
|
||||
payee_username: selectedSearch.payee_username || undefined,
|
||||
}
|
||||
}, [dateRange, pageSize, selectedSearch, selectedStatus, selectedTypes])
|
||||
|
||||
/* 清空所有筛选 */
|
||||
const clearAllFilters = () => {
|
||||
setSelectedTypes(initialType ? [initialType] : [])
|
||||
setSelectedStatus(null)
|
||||
setDateRange(null)
|
||||
setSelectedQuickSelection(null)
|
||||
setSelectedSearch({})
|
||||
}
|
||||
|
||||
/* 当筛选条件改变时,重新加载数据 */
|
||||
React.useEffect(() => {
|
||||
fetchTransactions(buildQueryParams(1))
|
||||
}, [buildQueryParams, fetchTransactions])
|
||||
|
||||
/* 当initialType改变时,更新筛选状态 */
|
||||
React.useEffect(() => {
|
||||
if (initialType) {
|
||||
setSelectedTypes([initialType])
|
||||
} else {
|
||||
setSelectedTypes([])
|
||||
}
|
||||
}, [initialType])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<TableFilter
|
||||
enabledFilters={{
|
||||
type: initialType === undefined,
|
||||
status: true,
|
||||
timeRange: true,
|
||||
search: true
|
||||
}}
|
||||
selectedTypes={selectedTypes}
|
||||
selectedStatus={selectedStatus}
|
||||
selectedTimeRange={dateRange}
|
||||
selectedQuickSelection={selectedQuickSelection}
|
||||
onTypeChange={setSelectedTypes}
|
||||
onStatusChange={setSelectedStatus}
|
||||
onTimeRangeChange={setDateRange}
|
||||
onQuickSelectionChange={setSelectedQuickSelection}
|
||||
onSearch={setSelectedSearch}
|
||||
searchValues={selectedSearch}
|
||||
onClearAll={clearAllFilters}
|
||||
enablePagination={true}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={goToPage}
|
||||
onPageSizeChange={setPageSizeHandler}
|
||||
onRefresh={clearCacheAndRefresh}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<TransactionTableList
|
||||
loading={loading}
|
||||
error={error}
|
||||
transactions={transactions}
|
||||
onRetry={() => fetchTransactions(buildQueryParams(1))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user