mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 09:06:36 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -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>
|
||||
确定要删除应用 “{apiKey.app_name}” 吗?
|
||||
此操作将永久删除该应用的所有凭证和配置,且无法恢复。
|
||||
使用此应用凭证的所有集成将立即失效。
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user