压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
@@ -0,0 +1,183 @@
"use client"
import * as React from "react"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { Undo2, FileText, Link2 } from "lucide-react"
import { toast } from "sonner"
import { TableFilter, mapDisplayStatusToQuery, type DisplayOrderStatus, type SearchValues } from "@/components/common/general/table-filter"
import { TransactionTableList } from "@/components/common/general/table-data"
import { type MerchantAPIKey, type OrderType } from "@/lib/services"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { formatLocalDate } from "@/lib/utils"
/** 集市功能列表 */
const MERCHANT_ACTIONS = [
{ title: "处理争议", description: "获取此应用的所有争议", icon: Undo2, color: "text-orange-600", bgColor: "bg-orange-50 dark:bg-orange-950/20", action: "refund" as const },
{ title: "所有活动", description: "显示此应用的所有活动", icon: FileText, color: "text-blue-600", bgColor: "bg-blue-50 dark:bg-blue-950/20", action: "view-all" as const },
{ title: "在线流转", description: "创建在线积分流转活动", icon: Link2, color: "text-purple-600", bgColor: "bg-purple-50 dark:bg-purple-950/20", action: "online-payment" as const },
]
/** 获取默认日期范围(最近30天) */
const getDefaultDateRange = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setDate(from.getDate() - 29)
return { from, to: today }
}
interface MerchantDataProps {
apiKey: MerchantAPIKey
}
/**
* 集市数据组件
* 显示应用的活动数据和统计信息
*/
export function MerchantData({ apiKey }: MerchantDataProps) {
const { from: start, to: end } = getDefaultDateRange()
return (
<TransactionProvider
defaultParams={{
page_size: 20,
startTime: formatLocalDate(start),
endTime: formatLocalDate(end),
client_id: apiKey.client_id
}}
>
<MerchantDataContent apiKey={apiKey} />
</TransactionProvider>
)
}
/**
* 集市数据内容组件
*/
function MerchantDataContent({ apiKey }: MerchantDataProps) {
const router = useRouter()
const { transactions, total, currentPage, totalPages, pageSize, loading, error, fetchTransactions, goToPage, setPageSize: setPageSizeHandler } = useTransaction()
const [selectedTypes, setSelectedTypes] = React.useState<OrderType[]>([])
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) => {
const stateQuery = mapDisplayStatusToQuery(selectedStatus)
return {
page,
page_size: pageSize,
types: selectedTypes.length > 0 ? selectedTypes : undefined,
...stateQuery,
startTime: dateRange ? formatLocalDate(dateRange.from) : undefined,
endTime: dateRange ? (() => {
const endDate = new Date(dateRange.to)
endDate.setHours(0, 0, 0, 0)
endDate.setDate(endDate.getDate() + 1)
return formatLocalDate(endDate)
})() : undefined,
client_id: apiKey.client_id,
id: selectedSearch.id || undefined,
order_name: selectedSearch.order_name || undefined,
payer_username: selectedSearch.payer_username || undefined,
payee_username: selectedSearch.payee_username || undefined,
}
}, [apiKey.client_id, dateRange, pageSize, selectedSearch, selectedStatus, selectedTypes])
const clearAllFilters = () => {
setSelectedTypes([])
setSelectedStatus(null)
setDateRange(null)
setSelectedQuickSelection(null)
setSelectedSearch({})
}
useEffect(() => {
fetchTransactions(buildQueryParams(1))
}, [buildQueryParams, fetchTransactions])
const actionHandlers = {
refund: () => {
setSelectedStatus('disputing')
toast.success('待处理的争议', { description: '显示此应用的所有待处理的争议' })
},
'view-all': () => {
clearAllFilters()
toast.success('全部活动', { description: '显示此应用的所有积分活动' })
},
'online-payment': () => router.push(`/merchant/online-paying?apiKeyId=${ apiKey.id }`)
}
return (
<div className="space-y-6">
<div>
<h2 className="font-semibold mb-4">应用服务</h2>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{MERCHANT_ACTIONS.map((action) => (
<button
key={action.action}
className="rounded-lg p-4 border border-dashed hover:border-primary/50 transition-all text-left group cursor-pointer"
onClick={() => actionHandlers[action.action]()}
>
<div className="flex items-center gap-4">
<div className={`rounded-lg p-2 ${ action.bgColor }`}>
<action.icon className={`size-4 ${ action.color }`} />
</div>
<div className="flex-1">
<h3 className="font-medium text-sm group-hover:text-foreground">{action.title}</h3>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</div>
</button>
))}
</div>
</div>
<div>
<h2 className="font-semibold mb-4">活动记录</h2>
<div className="space-y-2">
<TableFilter
enabledFilters={{
type: true,
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}
loading={loading}
/>
<TransactionTableList
loading={loading}
error={error}
transactions={transactions}
onRetry={() => fetchTransactions(buildQueryParams(1))}
emptyDescription="未发现积分活动记录"
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,366 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { Info } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import services, { type MerchantAPIKey, type CreateAPIKeyRequest, type UpdateAPIKeyRequest } from "@/lib/services"
interface MerchantDialogProps {
/** 模式:创建或更新 */
mode: 'create' | 'update'
/** API Key*/
apiKey?: MerchantAPIKey
/** 创建成功回调 */
onSuccess: (newKey: MerchantAPIKey) => void
/** 更新成功回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 触发按钮 */
trigger?: React.ReactNode
/** 自定义创建函数 */
createAPIKey?: (data: CreateAPIKeyRequest) => Promise<MerchantAPIKey>
/** 更新函数 */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 集市中心应用对话框组件
* 负责创建和更新集市中心应用的表单和验证逻辑
*/
export function MerchantDialog({
mode,
apiKey,
onSuccess,
onUpdate,
trigger,
createAPIKey,
updateAPIKey
}: MerchantDialogProps) {
const [open, setOpen] = useState(false)
const [processing, setProcessing] = useState(false)
const [mounted, setMounted] = useState(false)
const [showNotifyTooltip, setShowNotifyTooltip] = useState(false)
const [tooltipSide, setTooltipSide] = useState<'top' | 'bottom'>('bottom')
const [formData, setFormData] = useState<CreateAPIKeyRequest | UpdateAPIKeyRequest>({
app_name: '',
app_homepage_url: '',
app_description: '',
redirect_uri: '',
notify_url: '',
public_key: '',
test_mode: false,
})
useEffect(() => {
setMounted(true)
}, [])
/**
* 获取初始表单数据
*/
const getInitialFormData = React.useCallback((): CreateAPIKeyRequest | UpdateAPIKeyRequest => {
if (mode === 'update' && apiKey) {
return {
app_name: apiKey.app_name,
app_homepage_url: apiKey.app_homepage_url,
app_description: apiKey.app_description,
redirect_uri: apiKey.redirect_uri,
notify_url: apiKey.notify_url,
public_key: apiKey.public_key,
test_mode: apiKey.test_mode,
}
}
return {
app_name: '',
app_homepage_url: '',
app_description: '',
redirect_uri: '',
notify_url: '',
test_mode: false,
}
}, [mode, apiKey])
/**
* 初始化表单数据
*/
useEffect(() => {
setFormData(getInitialFormData())
}, [getInitialFormData])
const isValidUrl = (url: string): boolean => {
if (!url || url.trim() === '') return false
try {
const urlObj = new URL(url.trim())
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false
}
}
const resetForm = () => {
setFormData(getInitialFormData())
}
const validateForm = (): { valid: boolean; error?: string } => {
/* 验证必填项 */
if (!formData.app_name || !formData.app_homepage_url || !formData.notify_url) {
return { valid: false, error: '请填写所有必填项' }
}
/* 验证应用名称长度 */
if (formData.app_name.length > 20) {
return { valid: false, error: '应用名称不能超过 20 个字符' }
}
/* 验证应用主页 URL */
if (!isValidUrl(formData.app_homepage_url)) {
return { valid: false, error: '应用主页 URL 格式不正确' }
}
/* 验证回调 URI */
if (formData.redirect_uri && !isValidUrl(formData.redirect_uri)) {
return { valid: false, error: '回调 URI 格式不正确' }
}
/* 验证通知 URL */
if (!isValidUrl(formData.notify_url)) {
return { valid: false, error: '通知 URL 格式不正确' }
}
/* 验证应用描述长度 */
if (formData.app_description && formData.app_description.length > 100) {
return { valid: false, error: '应用描述不能超过 100 个字符' }
}
return { valid: true }
}
const handleSubmit = async () => {
const validation = validateForm()
if (!validation.valid) {
toast.error('表单验证失败', {
description: validation.error
})
return
}
try {
setProcessing(true)
if (mode === 'create') {
const newKey = await (createAPIKey ? createAPIKey(formData as CreateAPIKeyRequest) : services.merchant.createAPIKey(formData as CreateAPIKeyRequest))
toast.success('创建成功', {
description: '新应用已创建,请妥善保管您的 Client Secret'
})
onSuccess(newKey)
} else if (mode === 'update' && apiKey) {
if (updateAPIKey) {
await updateAPIKey(apiKey.id, formData as UpdateAPIKeyRequest)
} else {
await services.merchant.updateAPIKey(apiKey.id, formData as UpdateAPIKeyRequest)
}
toast.success('更新成功', {
description: '应用信息已更新'
})
/* 更新本地状态 */
const updatedKey = { ...apiKey, ...formData }
onUpdate?.(updatedKey)
}
setOpen(false)
} catch (error) {
const errorMessage = (error as Error).message || `无法${mode === 'create' ? '创建' : '更新'}应用`
toast.error(`${mode === 'create' ? '创建' : '更新'}失败`, {
description: errorMessage
})
throw error
} finally {
setProcessing(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !processing) {
resetForm()
}
setOpen(newOpen)
}
if (!mounted) {
return null
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button className="h-8 text-xs">
{mode === 'create' ? '创建应用' : '更新应用'}
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === 'create' ? '创建应用' : '更新应用信息'}</DialogTitle>
<DialogDescription>
{mode === 'create' ? '您可以创建一个集市中心应用来接入积分服务,请仔细填写以下信息' : '修改集市中心应用的基本信息和配置'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="app_name">应用名称 <span className="text-red-500">*</span></Label>
<Input
id="app_name"
placeholder="您的应用名称。最多 20 个字符,用于标识您的应用"
maxLength={20}
value={formData.app_name}
onChange={(e) => setFormData({ ...formData, app_name: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="app_description">应用描述</Label>
<Input
id="app_description"
placeholder="您的应用描述。最多 100 个字符,用于描述您的应用,可选"
maxLength={100}
value={formData.app_description}
onChange={(e) => setFormData({ ...formData, app_description: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="app_homepage_url">应用主页 URL <span className="text-red-500">*</span></Label>
<Input
id="app_homepage_url"
type="url"
placeholder="URL 必须包含 http:// 或 https:// ,用于展示您的应用主页"
maxLength={100}
value={formData.app_homepage_url}
onChange={(e) => setFormData({ ...formData, app_homepage_url: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="notify_url">通知 URL <span className="text-red-500">*</span></Label>
<TooltipProvider>
<Tooltip open={showNotifyTooltip} onOpenChange={setShowNotifyTooltip}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onMouseEnter={() => {
setTooltipSide('bottom')
setShowNotifyTooltip(true)
}}
onClick={() => {
setTooltipSide('bottom')
setShowNotifyTooltip(!showNotifyTooltip)
}}
>
<Info className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide} className="w-64">
<div className="space-y-2">
<p className="text-xs">如果您只需要使用在线积分流转服务,建议直接填写 http://localhost:3000/ 即可</p>
<Button
size="sm"
variant="secondary"
className="w-full h-6 text-xs"
onClick={() => {
setFormData({ ...formData, notify_url: 'http://localhost:3000/' })
setShowNotifyTooltip(false)
}}
>
快速填入
</Button>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Input
id="notify_url"
type="url"
placeholder="URL 必须为包含 http:// 或 https:// ,用于接收积分服务成功的异步通知"
maxLength={100}
value={formData.notify_url}
onChange={(e) => {
const value = e.target.value
setFormData({ ...formData, notify_url: value })
if (value.trim()) {
setShowNotifyTooltip(false)
}
}}
onClick={() => {
if (!formData.notify_url || !formData.notify_url.trim()) {
setTooltipSide('top')
setShowNotifyTooltip(true)
}
}}
onBlur={() => setShowNotifyTooltip(false)}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="redirect_uri">回调 URI</Label>
<Input
id="redirect_uri"
type="url"
placeholder="URL 必须包含 http:// 或 https:// ,用于接收积分服务完成后的回调,可选"
maxLength={100}
value={formData.redirect_uri}
onChange={(e) => setFormData({ ...formData, redirect_uri: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="public_key">Ed25519 公钥 (Base64)</Label>
<Input
id="public_key"
placeholder="可选,用于请求签名验证,必须为 32 字节的 Ed25519 公钥(Base64 编码)"
maxLength={100}
value={formData.public_key || ''}
onChange={(e) => setFormData({ ...formData, public_key: e.target.value })}
disabled={processing}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={processing} className="h-8 text-xs">取消</Button>
</DialogClose>
<Button
onClick={(e) => {
e.preventDefault()
handleSubmit()
}}
disabled={processing}
className="h-8 text-xs"
>
{processing ? <><Spinner /> {mode === 'create' ? '创建中' : '更新中'}</> : (mode === 'create' ? '创建' : '更新')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog >
)
}
@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { toast } from "sonner"
import { Coins } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { MerchantService, type MerchantAPIKey } from "@/lib/services"
interface DistributeDialogProps {
/** 自定义触发器 */
trigger?: React.ReactNode
/** 商户凭证 */
apiKey?: Pick<MerchantAPIKey, "client_id" | "client_secret">
}
/**
* 商户分发对话框
* 商户向用户分发积分
*/
export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [userId, setUserId] = useState("")
const [username, setUsername] = useState("")
const [amount, setAmount] = useState("")
const [remark, setRemark] = useState("")
const resetForm = () => {
setUserId("")
setUsername("")
setAmount("")
setRemark("")
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !loading) {
resetForm()
}
setOpen(newOpen)
}
const handleSubmit = async () => {
if (!userId.trim()) {
toast.error('表单验证失败', { description: '请填写用户 ID' })
return
}
if (!username.trim()) {
toast.error('表单验证失败', { description: '请填写用户名' })
return
}
if (!amount.trim()) {
toast.error('表单验证失败', { description: '请填写分发金额' })
return
}
const amountNum = parseFloat(amount)
if (isNaN(amountNum) || amountNum <= 0) {
toast.error('表单验证失败', { description: '分发金额必须大于 0' })
return
}
if (amountNum > 999999.99) {
toast.error('表单验证失败', { description: '分发金额不能超过 999999.99' })
return
}
if (remark.length > 100) {
toast.error('表单验证失败', { description: '备注不能超过 100 个字符' })
return
}
try {
setLoading(true)
if (!apiKey?.client_id || !apiKey?.client_secret) {
toast.error('缺少应用凭证', { description: '请先创建应用并确认凭证可用' })
return
}
const result = await MerchantService.distribute({
user_id: Number(userId.trim()),
username: username.trim(),
amount: amountNum,
remark: remark.trim() || undefined,
}, {
client_id: apiKey.client_id,
client_secret: apiKey.client_secret,
})
toast.success('分发成功', {
description: `订单号: ${ result.trade_no }`
})
setOpen(false)
resetForm()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '分发失败'
toast.error('分发失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="text-xs h-8 border-dashed w-full shadow-none">
<Coins className="size-3 mr-1" />
积分分发
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>积分分发</DialogTitle>
<DialogDescription>
向指定用户分发积分。您的积分将被扣除,对方收到的积分会扣除分发费率。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="user-id" className="text-xs">
用户 ID <span className="text-destructive">*</span>
</Label>
<Input
id="user-id"
placeholder="被分发者的用户 ID"
value={userId}
onChange={(e) => setUserId(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="username" className="text-xs">
用户名 <span className="text-destructive">*</span>
</Label>
<Input
id="username"
placeholder="被分发者的用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="amount" className="text-xs">
分发积分 <span className="text-destructive">*</span>
</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
max="999999.99"
placeholder="积分数量,用户将收到扣除分发费率后的积分"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="remark" className="text-xs">
备注
</Label>
<Textarea
id="remark"
placeholder="积分分发备注,最多100字 (可选)"
value={remark}
onChange={(e) => setRemark(e.target.value)}
disabled={loading}
maxLength={100}
className="h-16 text-xs resize-none"
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleSubmit() }}
disabled={loading}
className="h-8 text-xs"
>
{loading ? <><Spinner /> 分发中</> : '分发'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,263 @@
import * as React from "react"
import { useState } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { Copy, Eye, EyeOff, Trash2, ExternalLink, Edit } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { MerchantDialog } from "@/components/common/merchant/merchant-dialog"
import { TestModeToggle } from "@/components/common/merchant/merchant-test"
import { DistributeDialog } from "@/components/common/merchant/merchant-distribute"
import { formatDateTime } from "@/lib/utils"
import { type UpdateAPIKeyRequest, type MerchantAPIKey } from "@/lib/services"
interface MerchantInfoProps {
/** API Key */
apiKey: MerchantAPIKey
/** 更新回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 删除回调 */
onDelete: (id: string) => void
/** 更新 API Key */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 集市中心应用信息组件
* 显示集市中心应用的凭证信息(Client ID 和 Secret)
*/
export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: MerchantInfoProps) {
const [showClientId, setShowClientId] = useState(false)
const [showClientSecret, setShowClientSecret] = useState(false)
/* 复制到剪贴板 */
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success(`${ label } 已复制`)
} catch {
try {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
toast.success(`${ label } 已复制`)
} else {
toast.error('复制失败')
}
} catch {
toast.error('复制失败')
}
}
}
const maskText = (text: string, showLength: number = 8) => {
if (text.length <= showLength * 2) return text
return `${ text.substring(0, showLength) }${ '•'.repeat(20) }${ text.substring(text.length - showLength) }`
}
return (
<div className="space-y-6 sticky top-0">
<div>
<h2 className="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>
<div className="flex items-center gap-2 max-w-[70%]">
<p className="text-xs font-medium truncate">{apiKey.app_name}</p>
{apiKey.test_mode && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-500/10 text-orange-600 border border-orange-500/20">
测试
</span>
)}
</div>
</div>
{apiKey.app_description && (
<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%]">{apiKey.app_description}</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 text-right max-w-[70%]">{formatDateTime(apiKey.created_at)}</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>
<Link
href={apiKey.app_homepage_url}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.app_homepage_url}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
{apiKey.redirect_uri && (
<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">回调 URI</label>
<Link
href={apiKey.redirect_uri}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.redirect_uri}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
)}
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">通知 URL</label>
<Link
href={apiKey.notify_url}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.notify_url}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
</div>
</div>
<div>
<h2 className="font-semibold mb-4">API 配置</h2>
<div className="border border-dashed rounded-lg px-3 py-2 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-muted-foreground">Client ID</label>
<span className="text-[10px] text-muted-foreground">客户端标识</span>
</div>
<div className="flex items-center p-2 h-8 border border-dashed rounded-sm">
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto p-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{showClientId ? apiKey.client_id : maskText(apiKey.client_id, 8)}
</code>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => setShowClientId(!showClientId)}
>
{showClientId ? <EyeOff className="size-3 text-muted-foreground" /> : <Eye className="size-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => copyToClipboard(apiKey.client_id, 'Client ID')}
>
<Copy className="size-3 text-muted-foreground" />
</Button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-muted-foreground">Client Secret</label>
<span className="text-[10px] text-muted-foreground">客户端密钥</span>
</div>
<div className="flex items-center p-2 h-8 border border-dashed rounded-sm">
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto p-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{showClientSecret ? apiKey.client_secret : '•'.repeat(40)}
</code>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => setShowClientSecret(!showClientSecret)}
>
{showClientSecret ? <EyeOff className="size-3 text-muted-foreground" /> : <Eye className="size-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
onClick={() => copyToClipboard(apiKey.client_secret, 'Client Secret')}
className="size-6 p-1"
>
<Copy className="size-3" />
</Button>
</div>
</div>
</div>
</div>
<div>
<h2 className="font-semibold mb-4">应用管理</h2>
<div className="grid grid-cols-2 gap-2">
<MerchantDialog
mode="update"
apiKey={apiKey}
onSuccess={() => { }}
onUpdate={onUpdate}
updateAPIKey={updateAPIKey}
trigger={
<Button variant="outline" className="text-xs text-primary h-8 border-dashed border-primary/50 hover:bg-primary/5 w-full">
<Edit className="size-3 mr-1" />
编辑应用
</Button>
}
/>
<DistributeDialog apiKey={apiKey} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-xs text-destructive h-8 border-dashed border-destructive/50 hover:bg-destructive/5 w-full">
<Trash2 className="size-3 mr-1" />
删除应用
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除应用</AlertDialogTitle>
<AlertDialogDescription>
确定要删除应用 &ldquo;{apiKey.app_name}&rdquo; 吗?
此操作将永久删除该应用的所有凭证和配置,且无法恢复。
使用此应用凭证的所有集成将立即失效。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(apiKey.id)}
className="bg-destructive hover:bg-destructive/90"
>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestModeToggle
apiKey={apiKey}
onUpdate={onUpdate}
updateAPIKey={updateAPIKey}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { toast } from "sonner"
import { ErrorDisplay } from "@/components/layout/error"
import { EmptyState } from "@/components/layout/empty"
import { LoadingPage } from "@/components/layout/loading"
import { MerchantSelector } from "@/components/common/merchant/merchant-selector"
import { MerchantInfo } from "@/components/common/merchant/merchant-info"
import { MerchantData } from "@/components/common/merchant/merchant-data"
import { MerchantDialog } from "@/components/common/merchant/merchant-dialog"
import { type MerchantAPIKey } from "@/lib/services"
import { useMerchant } from "@/contexts/merchant-context"
/**
* 集市中心主页面组件
* 负责组装集市中心的各个子组件
*/
export function MerchantMain() {
const { apiKeys, loading, error, loadAPIKeys, createAPIKey, updateAPIKey, deleteAPIKey } = useMerchant()
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null)
const selectedKey = apiKeys.find(key => key.id === selectedKeyId) || null
/* 加载 API Keys */
useEffect(() => {
loadAPIKeys()
}, [loadAPIKeys])
/* 选择默认 API Key */
useEffect(() => {
if (apiKeys.length > 0 && !selectedKeyId) {
setSelectedKeyId(apiKeys[0].id)
}
}, [apiKeys, selectedKeyId])
/* 创建成功回调 */
const handleCreateSuccess = (newKey: MerchantAPIKey) => {
setSelectedKeyId(newKey.id)
}
/* 删除成功回调 */
const handleDelete = async (id: string) => {
try {
await deleteAPIKey(id)
toast.success('删除成功')
if (selectedKeyId === id) {
const remainingKeys = apiKeys.filter(key => key && key.id !== id)
setSelectedKeyId(remainingKeys.length > 0 ? remainingKeys[0].id : null)
}
} catch (error) {
toast.error('删除失败', {
description: (error as Error).message || '无法删除应用'
})
}
}
/* 正在加载中 */
if (loading) {
return <LoadingPage text="集市中心" badgeText="集市" />
}
return (
<div className="py-6">
<div className="flex items-center justify-between border-b pb-2 mb-4">
<h1 className="text-2xl font-semibold">集市中心</h1>
<div className="flex items-center gap-3">
{apiKeys.length > 0 && (
<MerchantSelector
apiKeys={apiKeys}
selectedKeyId={selectedKeyId}
onSelect={setSelectedKeyId}
/>
)}
<MerchantDialog
mode="create"
onSuccess={handleCreateSuccess}
createAPIKey={createAPIKey}
/>
</div>
</div>
{error ? (
<motion.div
key="error"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="flex items-center justify-center min-h-[60vh]"
>
<ErrorDisplay
title="加载失败"
message={error}
onRetry={loadAPIKeys}
retryText="重试"
/>
</motion.div>
) : apiKeys.length === 0 ? (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="flex items-center justify-center min-h-[60vh]"
>
<EmptyState
title="应用列表为空"
description="请创建您的第一个集市应用,开始您的积分服务"
/>
</motion.div>
) : (
<motion.div
key="content"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="space-y-6"
>
{selectedKey && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<MerchantData apiKey={selectedKey} />
</div>
<div className="lg:col-span-1">
<MerchantInfo
apiKey={selectedKey}
onDelete={handleDelete}
updateAPIKey={updateAPIKey}
/>
</div>
</div>
)}
</motion.div>
)}
</div>
)
}
@@ -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 >
)
}
@@ -0,0 +1,55 @@
import * as React from "react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import type { MerchantAPIKey } from "@/lib/services"
interface MerchantSelectorProps {
/** API Keys 列表 */
apiKeys: MerchantAPIKey[]
/** 选中的 API Key */
selectedKeyId: string | null
/** 选择回调 */
onSelect: (id: string) => void
/** 是否正在加载 */
loading?: boolean
}
/**
* 应用选择器组件
* 用于在多个应用之间切换
*/
export function MerchantSelector({
apiKeys,
selectedKeyId,
onSelect,
loading = false,
}: MerchantSelectorProps) {
return (
<Select
value={selectedKeyId || ''}
onValueChange={(value) => onSelect(value)}
disabled={loading || apiKeys.length === 0}
>
<SelectTrigger className="w-fit h-8 text-xs" size="sm">
<SelectValue placeholder="请选择应用" />
</SelectTrigger>
<SelectContent>
{apiKeys.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
title={apiKey.app_description ? `${ apiKey.app_name } - ${ apiKey.app_description }` : apiKey.app_name}
>
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{apiKey.app_name}</span>
{apiKey.app_description && (
<span className="text-xs text-muted-foreground truncate">
- {apiKey.app_description}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,176 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { toast } from "sonner"
import { Power, PowerOff } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import type { MerchantAPIKey, UpdateAPIKeyRequest } from "@/lib/services"
interface TestModeToggleProps {
/** API Key */
apiKey: MerchantAPIKey
/** 更新回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 更新 API Key */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 测试模式切换组件
* 提供测试模式开关按钮,带有详细的说明提示
*/
export function TestModeToggle({ apiKey, onUpdate, updateAPIKey }: TestModeToggleProps) {
const [open, setOpen] = useState(false)
const [processing, setProcessing] = useState(false)
const [pendingValue, setPendingValue] = useState<boolean>(false)
const handleToggle = async () => {
try {
setProcessing(true)
const updateData = {
app_name: apiKey.app_name,
app_homepage_url: apiKey.app_homepage_url,
app_description: apiKey.app_description,
redirect_uri: apiKey.redirect_uri,
notify_url: apiKey.notify_url,
test_mode: pendingValue
}
if (updateAPIKey) {
await updateAPIKey(apiKey.id, updateData)
} else {
const services = await import("@/lib/services")
await services.default.merchant.updateAPIKey(apiKey.id, updateData)
}
toast.success(pendingValue ? '测试模式已开启' : '测试模式已关闭', {
description: pendingValue
? '现在订单不会真实扣费,仅用于测试调试'
: '现在订单将正常扣费,可以接收真实付款'
})
const updatedKey = { ...apiKey, test_mode: pendingValue }
onUpdate?.(updatedKey)
setOpen(false)
} catch (error) {
const errorMessage = (error as Error).message || '更新测试模式失败'
toast.error('更新失败', { description: errorMessage })
} finally {
setProcessing(false)
}
}
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
setPendingValue(!apiKey.test_mode)
setOpen(true)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button
variant="outline"
onClick={handleClick}
className={`h-8 text-xs border-dashed w-full ${ apiKey.test_mode
? "text-orange-600 border-orange-500/50 hover:bg-orange-500/10"
: "text-green-600 border-green-500/50 hover:bg-green-500/10"
}`}
>
{apiKey.test_mode ? (
<Power className="size-3 mr-1" />
) : (
<PowerOff className="size-3 mr-1" />
)}
{apiKey.test_mode ? "关闭测试" : "开启测试"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{pendingValue ? (
<>
<Power className="size-5 text-orange-600" />
开启测试模式
</>
) : (
<>
<PowerOff className="size-5 text-green-600" />
关闭测试模式
</>
)}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3 text-sm">
{pendingValue ? (
<>
<p className="font-medium">测试模式开启后:</p>
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
<li><strong>订单不会真实扣费</strong>:用户余额不会扣减</li>
<li><strong>商户不会收款</strong>:你的余额不会增加</li>
<li><strong>仅限自己测试</strong>:只有你可以支付测试订单</li>
<li><strong>订单标记明显</strong>:测试订单会显示警告标识</li>
</ul>
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-3">
<p className="text-xs text-orange-800 dark:text-orange-300">
<strong>使用场景:</strong>在正式上线前测试支付流程、回调通知等功能
</p>
</div>
</>
) : (
<>
<p className="font-medium">测试模式关闭后:</p>
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
<li><strong>订单真实扣费</strong>:用户余额会正常扣减</li>
<li><strong>商户正常收款</strong>:你的余额会正常增加</li>
<li><strong>其他用户可支付</strong>:任何人都可以支付订单</li>
<li><strong>正式生产环境</strong>:可以接收真实的业务订单</li>
</ul>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
<p className="text-xs text-green-800 dark:text-green-300">
<strong>注意:</strong>关闭后应用将进入生产模式,请确保已完成测试
</p>
</div>
</>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={processing}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleToggle()
}}
disabled={processing}
className={pendingValue ? "bg-orange-600 hover:bg-orange-700" : "bg-green-600 hover:bg-green-700"}
>
{processing ? (
<>
<Spinner className="size-4" />
{pendingValue ? '开启中' : '关闭中'}
</>
) : (
pendingValue ? '确认开启' : '确认关闭'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}