mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 18:36:37 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,836 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Key, Smartphone, Tablet, Monitor, RotateCw, Plus, Trash2, Copy, ExternalLink, CreditCard, Store, Loader2, Eye, EyeOff, Expand, Minimize, Sun, Moon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet"
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||
import { useMerchant } from "@/contexts/merchant-context"
|
||||
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { MerchantService, ConfigService, type PaymentLink, type GetMerchantOrderResponse, type MerchantAPIKey, type UserPayConfig } from "@/lib/services"
|
||||
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
|
||||
import { PayingNow } from "@/components/common/pay/paying/paying-now"
|
||||
import { MerchantSelector } from "@/components/common/merchant/merchant-selector"
|
||||
import { LoadingPage } from "@/components/layout/loading"
|
||||
import { EmptyState } from "@/components/layout/empty"
|
||||
import { TransactionTableList } from "@/components/common/general/table-data"
|
||||
|
||||
/** 设备预览配置 */
|
||||
const DEVICE_CONFIG = {
|
||||
mobile: {
|
||||
width: 375,
|
||||
height: 812,
|
||||
borderRadius: 50,
|
||||
borderWidth: 14,
|
||||
scale: 0.6
|
||||
},
|
||||
tablet: {
|
||||
width: 768,
|
||||
height: 1024,
|
||||
borderRadius: 32,
|
||||
borderWidth: 18,
|
||||
scale: 0.45
|
||||
},
|
||||
desktop: {
|
||||
width: 1200,
|
||||
height: 750,
|
||||
borderRadius: 16,
|
||||
borderWidth: 8,
|
||||
scale: 0.35
|
||||
}
|
||||
} as const
|
||||
|
||||
export function MerchantOnline() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { apiKeys, loading: loadingKeys, loadAPIKeys } = useMerchant()
|
||||
|
||||
useEffect(() => {
|
||||
loadAPIKeys()
|
||||
}, [loadAPIKeys])
|
||||
const apiKeyId = searchParams.get("apiKeyId")
|
||||
const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null
|
||||
|
||||
if (loadingKeys) {
|
||||
return <LoadingPage text="在线流转" badgeText="积分" />
|
||||
}
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between border-b pb-2 mb-4 shrink-0">
|
||||
<h1 className="text-2xl font-semibold">在线流转</h1>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => router.push('/merchant')}>
|
||||
<Store className="size-3 mr-1" /> 前往集市
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<EmptyState
|
||||
title="暂未创建应用"
|
||||
description="您还没有创建任何应用。请前往集市中心创建应用,即可开始使用在线积分流转服务。"
|
||||
icon={Key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TransactionProvider
|
||||
defaultParams={{
|
||||
page_size: 20,
|
||||
types: ['online'],
|
||||
client_id: selectedKey?.client_id
|
||||
}}
|
||||
>
|
||||
<MerchantOnlineContent apiKeys={apiKeys} />
|
||||
</TransactionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
interface MerchantOnlineContentProps {
|
||||
apiKeys: MerchantAPIKey[]
|
||||
}
|
||||
|
||||
function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const {
|
||||
transactions,
|
||||
loading: loadingTransactions,
|
||||
error: transactionsError,
|
||||
fetchTransactions,
|
||||
refresh
|
||||
} = useTransaction()
|
||||
|
||||
/* 派生 selectedKey */
|
||||
const apiKeyId = searchParams.get("apiKeyId")
|
||||
|
||||
/* 处理集市应用切换 */
|
||||
const handleMerchantSelect = useCallback((id: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("apiKeyId", id.toString())
|
||||
router.push(`${ window.location.pathname }?${ params.toString() }`)
|
||||
}, [searchParams, router])
|
||||
|
||||
/* 自动选择第一个集市应用 */
|
||||
useEffect(() => {
|
||||
if (apiKeys.length > 0 && !apiKeyId) {
|
||||
handleMerchantSelect(apiKeys[0].id)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [apiKeys.length, apiKeyId])
|
||||
|
||||
const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null
|
||||
|
||||
/* 集市状态 */
|
||||
const [paymentLinks, setPaymentLinks] = useState<PaymentLink[]>([])
|
||||
const [selectedLink, setSelectedLink] = useState<PaymentLink | null>(null)
|
||||
const [previewLink, setPreviewLink] = useState<PaymentLink | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/* 创建/编辑表单状态 */
|
||||
const [productName, setProductName] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [remark, setRemark] = useState("")
|
||||
const [totalLimit, setTotalLimit] = useState("")
|
||||
const [userLimit, setUserLimit] = useState("")
|
||||
|
||||
/* 设备预览状态 */
|
||||
const [previewDevice, setPreviewDevice] = useState<'mobile' | 'tablet' | 'desktop'>('mobile')
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light')
|
||||
|
||||
/* 获取应用列表 */
|
||||
const fetchLinks = useCallback(async () => {
|
||||
if (!selectedKey) {
|
||||
setPaymentLinks([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const links = await MerchantService.listPaymentLinks(selectedKey.id)
|
||||
setPaymentLinks(links)
|
||||
/* 自动设置第一个应用为预览 */
|
||||
if (links.length > 0) {
|
||||
setPreviewLink(prev => prev || links[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("获取应用列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedKey])
|
||||
|
||||
/* 当 selectedKey 变化时,重新获取应用列表和数据 */
|
||||
useEffect(() => {
|
||||
if (selectedKey?.client_id) {
|
||||
fetchLinks()
|
||||
fetchTransactions({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
types: ['online'],
|
||||
client_id: selectedKey.client_id
|
||||
})
|
||||
setIsCreating(false)
|
||||
setSelectedLink(null)
|
||||
} else {
|
||||
setPaymentLinks([])
|
||||
setIsCreating(false)
|
||||
setSelectedLink(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedKey?.client_id])
|
||||
|
||||
/* 处理创建 */
|
||||
const handleCreate = async () => {
|
||||
if (!selectedKey) return
|
||||
if (!productName || !amount) {
|
||||
toast.error("请填写在线积分流转服务的名称和数量")
|
||||
return
|
||||
}
|
||||
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
|
||||
toast.error("积分数量必须设置为大于0的数值")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证限制字段
|
||||
if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) {
|
||||
toast.error("总数量限制必须是大于0的整数")
|
||||
return
|
||||
}
|
||||
if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) {
|
||||
toast.error("单用户限制必须是大于0的整数")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const newLink = await MerchantService.createPaymentLink(selectedKey.id, {
|
||||
product_name: productName,
|
||||
amount: parseFloat(amount),
|
||||
remark,
|
||||
...(totalLimit && { total_limit: parseInt(totalLimit) }),
|
||||
...(userLimit && { user_limit: parseInt(userLimit) })
|
||||
})
|
||||
toast.success("在线积分流转服务创建成功")
|
||||
/* 重置表单 */
|
||||
setProductName("")
|
||||
setAmount("")
|
||||
setRemark("")
|
||||
setTotalLimit("")
|
||||
setUserLimit("")
|
||||
|
||||
/* 更新UI */
|
||||
fetchLinks()
|
||||
setIsCreating(false)
|
||||
setSelectedLink(newLink)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "在线积分流转服务创建失败"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 处理删除 */
|
||||
const handleDelete = async (link: PaymentLink) => {
|
||||
if (!selectedKey) return
|
||||
if (!confirm("确定要删除这个在线积分流转服务吗?该操作不可恢复。")) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await MerchantService.deletePaymentLink(selectedKey.id, link.id)
|
||||
toast.success("在线积分流转服务已删除")
|
||||
if (selectedLink?.id === link.id) {
|
||||
setSelectedLink(null)
|
||||
}
|
||||
fetchLinks()
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "在线积分流转服务删除失败"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 处理编辑 */
|
||||
const handleEdit = (link: PaymentLink) => {
|
||||
setProductName(link.product_name)
|
||||
setAmount(link.amount)
|
||||
setRemark(link.remark || "")
|
||||
setTotalLimit(link.total_limit?.toString() || "")
|
||||
setUserLimit(link.user_limit?.toString() || "")
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
/* 处理更新 */
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedKey || !selectedLink) return
|
||||
if (!productName || !amount) {
|
||||
toast.error("请填写在线积分流转服务的名称和数量")
|
||||
return
|
||||
}
|
||||
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
|
||||
toast.error("积分数量必须设置为大于0的数值")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证限制字段
|
||||
if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) {
|
||||
toast.error("总数量限制必须是大于0的整数")
|
||||
return
|
||||
}
|
||||
if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) {
|
||||
toast.error("单用户限制必须是大于0的整数")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await MerchantService.updatePaymentLink(selectedKey.id, selectedLink.id, {
|
||||
product_name: productName,
|
||||
amount: parseFloat(amount),
|
||||
remark,
|
||||
...(totalLimit && { total_limit: parseInt(totalLimit) }),
|
||||
...(userLimit && { user_limit: parseInt(userLimit) })
|
||||
})
|
||||
toast.success("在线积分流转服务已更新")
|
||||
|
||||
/* 更新UI */
|
||||
fetchLinks()
|
||||
setIsEditing(false)
|
||||
setSelectedLink(null)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "在线积分流转服务更新失败"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 处理复制链接 */
|
||||
const handleCopyLink = (token: string) => {
|
||||
const url = `${ window.location.origin }/paying/online?token=${ token }`
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success("在线积分流转服务链接已复制")
|
||||
}
|
||||
|
||||
/* 获取用户支付配置 */
|
||||
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const configs = await ConfigService.getUserPayConfigs()
|
||||
setUserPayConfigs(configs)
|
||||
} catch (error) {
|
||||
console.error("Failed to load user pay configs:", error)
|
||||
}
|
||||
}
|
||||
loadConfigs()
|
||||
}, [])
|
||||
|
||||
/* 预览订单信息 */
|
||||
const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level)
|
||||
|
||||
const previewOrderInfo: GetMerchantOrderResponse = {
|
||||
merchant: {
|
||||
app_name: selectedKey?.app_name || "服务名称",
|
||||
redirect_uri: "",
|
||||
},
|
||||
order: {
|
||||
id: '0',
|
||||
order_no: previewLink ? `LINK-${ previewLink.id }` : "PREVIEW",
|
||||
order_name: isCreating ? (productName || "服务名称") : (previewLink?.product_name || "服务名称"),
|
||||
payer_username: "user",
|
||||
payee_username: selectedKey?.app_name || "应用",
|
||||
amount: isCreating ? (amount || "0.00") : (previewLink?.amount || "0.00"),
|
||||
status: "pending",
|
||||
type: "payment",
|
||||
payment_type: "api",
|
||||
remark: isCreating ? remark : (previewLink?.remark || ""),
|
||||
client_id: "",
|
||||
return_url: "",
|
||||
notify_url: "",
|
||||
trade_time: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
user_pay_config: currentUserConfig ? {
|
||||
...currentUserConfig,
|
||||
fee_rate: String(currentUserConfig.fee_rate)
|
||||
} : {
|
||||
id: '0',
|
||||
level: user?.pay_level ?? 0,
|
||||
min_score: 0,
|
||||
max_score: null,
|
||||
daily_limit: null,
|
||||
fee_rate: "0.00",
|
||||
created_at: "",
|
||||
updated_at: ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between border-b pb-2 mb-4 shrink-0">
|
||||
<h1 className="text-2xl font-semibold">在线流转</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{apiKeys.length > 0 && (
|
||||
<MerchantSelector
|
||||
apiKeys={apiKeys}
|
||||
selectedKeyId={selectedKey?.id || null}
|
||||
onSelect={handleMerchantSelect}
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => router.push('/merchant')}>
|
||||
<Store className="size-3 mr-1" /> 前往集市
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedKey ? (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<EmptyState
|
||||
title={apiKeys.length === 0 ? "暂未创建应用" : "未选择应用"}
|
||||
description={apiKeys.length === 0
|
||||
? "您还没有创建任何应用。请前往集市中心创建一个应用,即可开始管理在线积分流转服务。"
|
||||
: "请先在右上角选择一个应用以管理此应用的所有在线积分流转服务。"}
|
||||
icon={Key}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6 h-full min-h-0">
|
||||
<div className="space-y-4 shrink-0">
|
||||
<h2 className="font-semibold -mt-2">所有服务</h2>
|
||||
<ScrollArea className="w-full whitespace-nowrap">
|
||||
<div className="flex gap-4 pb-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreating(true)
|
||||
setSelectedLink(null)
|
||||
setProductName("")
|
||||
setAmount("")
|
||||
setRemark("")
|
||||
setTotalLimit("")
|
||||
setUserLimit("")
|
||||
}}
|
||||
className="rounded-lg p-4 border border-dashed hover:border-primary/50 shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<div className="rounded-lg p-2 bg-purple-50 dark:bg-purple-950/20">
|
||||
<Plus className="size-4 text-purple-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-medium text-sm group-hover:text-foreground">创建服务</h3>
|
||||
<p className="text-xs text-muted-foreground">添加新的在线积分流转服务</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{loading && paymentLinks.length === 0 && (
|
||||
<div className="flex items-center justify-center h-[100px] w-[180px] shrink-0">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentLinks.map((link) => (
|
||||
<button
|
||||
key={link.id}
|
||||
onClick={() => {
|
||||
if (previewLink?.id === link.id) {
|
||||
setSelectedLink(link)
|
||||
setIsCreating(false)
|
||||
} else {
|
||||
setPreviewLink(link)
|
||||
setIsCreating(false)
|
||||
toast.info("再次点击打开详情", {
|
||||
duration: 1500,
|
||||
position: "bottom-center"
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={`rounded-lg p-4 border border-dashed shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col justify-between ${ previewLink?.id === link.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm truncate">{link.product_name}</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">{link.remark || "无备注"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-1.5 bg-green-50 dark:bg-green-950/20 shrink-0">
|
||||
<CreditCard className="size-3 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mt-2">
|
||||
<span className="text-xs text-muted-foreground">LDC</span>
|
||||
<span className="text-lg font-mono font-semibold">{parseFloat(link.amount).toFixed(2)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="font-semibold">活动数据</h2>
|
||||
</div>
|
||||
<div className="h-[600px] overflow-hidden">
|
||||
<TransactionTableList
|
||||
loading={loadingTransactions}
|
||||
error={transactionsError}
|
||||
transactions={transactions}
|
||||
onRetry={refresh}
|
||||
emptyDescription="暂无在线流转服务数据"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-4 ${ isFullscreen ? 'fixed inset-0 z-50 bg-background/95 backdrop-blur-sm p-6 flex flex-col' : '' }`}>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="font-semibold">{isFullscreen ? '全屏预览' : '实时预览'}</h2>
|
||||
</div>
|
||||
<div className={`border-none overflow-hidden relative h-[600px] ${ isFullscreen ? 'border-0 rounded-none h-full flex-1' : '' }`}>
|
||||
<div className="absolute z-15 left-1/2 -translate-x-1/2 top-4">
|
||||
<div className="h-8 bg-background border border-border rounded-full p-1.5 flex items-center gap-0.5 shadow-lg">
|
||||
<Button variant={previewDevice === 'mobile' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('mobile')}>
|
||||
<Smartphone className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant={previewDevice === 'tablet' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('tablet')}>
|
||||
<Tablet className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant={previewDevice === 'desktop' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('desktop')}>
|
||||
<Monitor className="size-3.5" />
|
||||
</Button>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => setIsFullscreen(!isFullscreen)}>
|
||||
{isFullscreen ? <Minimize className="size-3.5" /> : <Expand className="size-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => { const current = previewDevice; setPreviewDevice('mobile'); setTimeout(() => setPreviewDevice(current), 50) }}>
|
||||
<RotateCw className="size-3.5" />
|
||||
</Button>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => setPreviewTheme(previewTheme === 'dark' ? 'light' : 'dark')}>
|
||||
{previewTheme === 'dark' ? <Sun className="size-3.5" /> : <Moon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex justify-center items-center p-6 relative overflow-hidden h-full ${ isFullscreen ? 'min-h-screen' : '' }`}>
|
||||
<div className="absolute inset-0 pattern-grid-lg opacity-5" />
|
||||
<motion.div
|
||||
layout
|
||||
initial={false}
|
||||
animate={{
|
||||
width: DEVICE_CONFIG[previewDevice].width,
|
||||
height: DEVICE_CONFIG[previewDevice].height,
|
||||
borderRadius: DEVICE_CONFIG[previewDevice].borderRadius,
|
||||
borderWidth: DEVICE_CONFIG[previewDevice].borderWidth,
|
||||
scale: isFullscreen
|
||||
? (previewDevice === 'mobile' ? 0.9 : previewDevice === 'tablet' ? 0.75 : 0.65)
|
||||
: DEVICE_CONFIG[previewDevice].scale,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 25 }}
|
||||
className="shrink-0 overflow-hidden shadow-2xl bg-black relative border-[#1f1f1f] origin-center rounded-[50px]"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{previewDevice === 'mobile' && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-10" key="mobile-frame">
|
||||
<div className="absolute top-[11px] left-1/2 -translate-x-1/2 w-[120px] h-[35px] bg-black rounded-full flex items-center justify-center gap-3 shadow-sm border border-white/5">
|
||||
<div className="w-2 h-2 rounded-full bg-[#1a1a1a]" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#0f0f0f]" />
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1.5 bg-white/20 rounded-full" />
|
||||
</motion.div>
|
||||
)}
|
||||
{previewDevice === 'tablet' && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-50" key="tablet-frame">
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full bg-gray-600 border border-gray-500" />
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1.5 bg-white/20 rounded-full" />
|
||||
</motion.div>
|
||||
)}
|
||||
{previewDevice === 'desktop' && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-50" key="desktop-frame">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-48 h-8 bg-[#1f1f1f] rounded-b-xl flex items-center justify-center border-b border-x border-black/50">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-black/50" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className={`h-full w-full overflow-y-auto custom-scrollbar flex items-center justify-center p-4 transition-colors duration-500 ${ previewTheme === 'dark' ? 'dark bg-zinc-900' : 'bg-white' }`}>
|
||||
<div className={`flex ${ previewDevice === 'mobile' ? 'flex-col' : 'flex-row' } w-full max-w-4xl backdrop-blur-2xl border rounded-3xl overflow-hidden shadow-2xl relative shrink-0 transition-colors duration-500 ${ previewTheme === 'dark' ? 'bg-card/70 border-border/50' : 'bg-white border-gray-200' }`}>
|
||||
<PayingInfo orderInfo={previewOrderInfo} loading={loading} forceMobile={previewDevice === 'mobile'} />
|
||||
<PayingNow orderInfo={previewOrderInfo} paying={false} payKey="" currentStep="method" selectedMethod="alipay" isOpen={false} loading={false} onPayKeyChange={() => { }} onCurrentStepChange={() => { }} onSelectedMethodChange={() => { }} onIsOpenChange={() => { }} onPayOrder={() => { }} forceMobile={previewDevice === 'mobile'} />
|
||||
<div className="absolute inset-0 z-50 bg-transparent cursor-default" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 z-40 bg-transparent cursor-default" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={isCreating || !!selectedLink} onOpenChange={(open) => { if (!open) { setIsCreating(false); setSelectedLink(null); } }}>
|
||||
<SheetContent className="w-full sm:max-w-md overflow-y-auto custom-scrollbar px-4">
|
||||
{isCreating ? (
|
||||
<div className="space-y-6">
|
||||
<SheetHeader className="px-0">
|
||||
<SheetTitle>创建新服务</SheetTitle>
|
||||
<SheetDescription className="text-xs">创建一个在线积分流转服务。</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="border border-dashed rounded-lg p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">服务名称 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="例如:高级会员订阅服务"
|
||||
value={productName}
|
||||
onChange={e => setProductName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">积分 (LDC) <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
className="font-mono"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">总次数限制 (可选)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="不限制"
|
||||
min="1"
|
||||
value={totalLimit}
|
||||
onChange={e => setTotalLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">设置该链接总共可以支付的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">单用户限制 (可选)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="不限制"
|
||||
min="1"
|
||||
value={userLimit}
|
||||
onChange={e => setUserLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">设置每个用户最多可以支付的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">备注 (可选)</Label>
|
||||
<Textarea
|
||||
placeholder="服务备注信息..."
|
||||
className="resize-none h-20"
|
||||
value={remark}
|
||||
onChange={e => setRemark(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex flex-row justify-end items-center gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsCreating(false)} className="h-8 text-xs">取消</Button>
|
||||
<Button onClick={handleCreate} disabled={loading} className="h-8 text-xs">
|
||||
{loading && <Spinner className="mr-1 size-3" />} 创建
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
) : selectedLink ? (
|
||||
<div className="space-y-6">
|
||||
<SheetHeader className="px-0">
|
||||
<SheetTitle>{selectedLink.product_name}</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
{isEditing ? "编辑在线积分流转服务信息" : "在线积分流转服务内容详情"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{isEditing ? (
|
||||
/* 编辑模式 */
|
||||
<div className="space-y-6">
|
||||
<div className="border border-dashed rounded-lg p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">服务名称 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="例如:高级会员订阅服务"
|
||||
value={productName}
|
||||
onChange={e => setProductName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">积分 (LDC) <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
className="font-mono"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">总次数限制 (可选)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="不限制"
|
||||
min="1"
|
||||
value={totalLimit}
|
||||
onChange={e => setTotalLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">设置该链接总共可以支付的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">单用户限制 (可选)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="不限制"
|
||||
min="1"
|
||||
value={userLimit}
|
||||
onChange={e => setUserLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">设置每个用户最多可以支付的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">备注 (可选)</Label>
|
||||
<Textarea
|
||||
placeholder="服务备注信息..."
|
||||
className="resize-none h-20"
|
||||
value={remark}
|
||||
onChange={e => setRemark(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex flex-row justify-end items-center gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsEditing(false)} className="h-8 text-xs">取消</Button>
|
||||
<Button onClick={handleUpdate} disabled={loading} className="h-8 text-xs">
|
||||
{loading && <Spinner className="mr-1 size-3" />} 保存
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
) : (
|
||||
/* 查看模式 */
|
||||
<>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold mb-4">服务信息</h2>
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">服务名称</label>
|
||||
<p className="text-xs font-medium truncate text-right max-w-[70%]">{selectedLink.product_name}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">积分数量</label>
|
||||
<p className="text-xs font-bold text-primary">LDC {parseFloat(selectedLink.amount).toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
{(selectedLink.total_limit || selectedLink.user_limit) && (
|
||||
<>
|
||||
{selectedLink.total_limit && (
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">总次数限制</label>
|
||||
<p className="text-xs font-medium">{selectedLink.total_limit}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedLink.user_limit && (
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">单用户限制</label>
|
||||
<p className="text-xs font-medium">{selectedLink.user_limit}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">服务备注</label>
|
||||
<p className="text-xs text-muted-foreground truncate text-right max-w-[70%]">{selectedLink.remark || "无备注"}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
|
||||
<p className="text-xs text-muted-foreground">{new Date(selectedLink.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-dashed last:border-b-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground shrink-0">服务令牌</label>
|
||||
<div className="flex items-center p-1 h-7 border border-dashed rounded-sm bg-background max-w-[200px]">
|
||||
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] whitespace-nowrap">
|
||||
{showToken ? selectedLink.token : '•'.repeat(36)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0.5 w-5 h-5 shrink-0"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-2.5 text-muted-foreground" /> : <Eye className="size-2.5 text-muted-foreground" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleCopyLink(selectedLink.token)}
|
||||
className="p-0.5 w-5 h-5 shrink-0"
|
||||
>
|
||||
<Copy className="size-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold mb-4">流转服务管理</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" className="text-xs h-8 border-dashed border-green-500 hover:bg-green-50 shadow-none" onClick={() => handleCopyLink(selectedLink.token)}>
|
||||
<Copy className="size-3 mr-1" />
|
||||
复制链接
|
||||
</Button>
|
||||
<Button variant="outline" className="text-xs h-8 border-dashed border-green-500 hover:bg-green-50 shadow-none" onClick={() => window.open(`/paying/online?token=${ selectedLink.token }`, '_blank')}>
|
||||
<ExternalLink className="size-3 mr-1" />
|
||||
查看服务
|
||||
</Button>
|
||||
<Button variant="outline" className="text-xs h-8 border-dashed border-blue-500 hover:bg-blue-50 shadow-none" onClick={() => handleEdit(selectedLink)}>
|
||||
<svg className="size-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
编辑服务
|
||||
</Button>
|
||||
<Button variant="outline" className="text-xs text-destructive h-8 border-dashed border-destructive/50 hover:bg-destructive/5 shadow-none" onClick={() => handleDelete(selectedLink)}>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
删除服务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user