feat: 全站多语言支持 (i18n),lang=en 显示英文,其余默认中文
新增 src/lib/locale.ts 作为统一多语言入口,覆盖前台支付链路、 管理后台、API/服务层错误文案,共 35 个文件。URL 参数 lang 全链路透传, 包括 Stripe return_url、页面跳转、layout html lang 属性等。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import OrderFilterBar from '@/components/OrderFilterBar';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import {
|
||||
formatStatus,
|
||||
formatCreatedAt,
|
||||
@@ -19,6 +20,7 @@ interface MobileOrderListProps {
|
||||
loadingMore: boolean;
|
||||
onRefresh: () => void;
|
||||
onLoadMore: () => void;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function MobileOrderList({
|
||||
@@ -29,6 +31,7 @@ export default function MobileOrderList({
|
||||
loadingMore,
|
||||
onRefresh,
|
||||
onLoadMore,
|
||||
locale = 'zh',
|
||||
}: MobileOrderListProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -59,7 +62,7 @@ export default function MobileOrderList({
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
我的订单
|
||||
{locale === 'en' ? 'My Orders' : '我的订单'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -71,11 +74,11 @@ export default function MobileOrderList({
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
刷新
|
||||
{locale === 'en' ? 'Refresh' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
|
||||
{!hasToken ? (
|
||||
<div
|
||||
@@ -84,7 +87,9 @@ export default function MobileOrderList({
|
||||
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
当前链接未携带登录 token,无法查询"我的订单"。
|
||||
{locale === 'en'
|
||||
? 'The current link does not include a login token, so "My Orders" is unavailable.'
|
||||
: '当前链接未携带登录 token,无法查询"我的订单"。'}
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div
|
||||
@@ -93,7 +98,7 @@ export default function MobileOrderList({
|
||||
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
暂无符合条件的订单记录
|
||||
{locale === 'en' ? 'No matching orders found' : '暂无符合条件的订单记录'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -110,26 +115,27 @@ export default function MobileOrderList({
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}
|
||||
>
|
||||
{formatStatus(order.status)}
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{getPaymentDisplayInfo(order.paymentType).channel}
|
||||
{getPaymentDisplayInfo(order.paymentType, locale).channel}
|
||||
</div>
|
||||
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{formatCreatedAt(order.createdAt)}
|
||||
{formatCreatedAt(order.createdAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 无限滚动哨兵 */}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-3 text-center">
|
||||
{loadingMore ? (
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>加载中...</span>
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{locale === 'en' ? 'Loading...' : '加载中...'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
|
||||
上滑加载更多
|
||||
{locale === 'en' ? 'Scroll up to load more' : '上滑加载更多'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,7 +143,7 @@ export default function MobileOrderList({
|
||||
|
||||
{!hasMore && orders.length > 0 && (
|
||||
<div className={['py-2 text-center text-xs', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
|
||||
已显示全部订单
|
||||
{locale === 'en' ? 'All orders loaded' : '已显示全部订单'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { FILTER_OPTIONS, type OrderStatusFilter } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { getFilterOptions, type OrderStatusFilter } from '@/lib/pay-utils';
|
||||
|
||||
interface OrderFilterBarProps {
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
activeFilter: OrderStatusFilter;
|
||||
onChange: (filter: OrderStatusFilter) => void;
|
||||
}
|
||||
|
||||
export default function OrderFilterBar({ isDark, activeFilter, onChange }: OrderFilterBarProps) {
|
||||
export default function OrderFilterBar({ isDark, locale, activeFilter, onChange }: OrderFilterBarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FILTER_OPTIONS.map((item) => (
|
||||
{getFilterOptions(locale).map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
|
||||
@@ -1,56 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface OrderStatusProps {
|
||||
status: string;
|
||||
onBack: () => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: string; message: string }> = {
|
||||
COMPLETED: {
|
||||
label: '充值成功',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
const STATUS_CONFIG: Record<Locale, Record<string, { label: string; color: string; icon: string; message: string }>> = {
|
||||
zh: {
|
||||
COMPLETED: {
|
||||
label: '充值成功',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
},
|
||||
PAID: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '正在充值余额中,请稍候...',
|
||||
},
|
||||
FAILED: {
|
||||
label: '充值失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '充值失败,请联系管理员处理。',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: '订单超时',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: '已取消',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已取消。',
|
||||
},
|
||||
},
|
||||
PAID: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '正在充值余额中,请稍候...',
|
||||
},
|
||||
FAILED: {
|
||||
label: '充值失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '充值失败,请联系管理员处理。',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: '订单超时',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: '已取消',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已取消。',
|
||||
en: {
|
||||
COMPLETED: {
|
||||
label: 'Recharge Successful',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: 'Your balance has been credited. Thank you for your payment.',
|
||||
},
|
||||
PAID: {
|
||||
label: 'Recharging',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Payment received. Recharging your balance...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: 'Recharging',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Recharging your balance. Please wait...',
|
||||
},
|
||||
FAILED: {
|
||||
label: 'Recharge Failed',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: 'Recharge failed. Please contact the administrator.',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: 'Order Expired',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: 'This order has expired. Please create a new order.',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: 'Cancelled',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: 'The order has been cancelled.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function OrderStatus({ status, onBack, dark = false }: OrderStatusProps) {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
export default function OrderStatus({ status, onBack, dark = false, locale = 'zh' }: OrderStatusProps) {
|
||||
const config = STATUS_CONFIG[locale][status] || {
|
||||
label: status,
|
||||
color: 'text-gray-600',
|
||||
icon: '?',
|
||||
message: '未知状态',
|
||||
message: locale === 'en' ? 'Unknown status' : '未知状态',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -65,7 +108,7 @@ export default function OrderStatus({ status, onBack, dark = false }: OrderStatu
|
||||
dark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-blue-600 hover:bg-blue-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{status === 'COMPLETED' ? '完成' : '返回充值'}
|
||||
{status === 'COMPLETED' ? (locale === 'en' ? 'Done' : '完成') : locale === 'en' ? 'Back to Recharge' : '返回充值'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface Summary {
|
||||
total: number;
|
||||
pending: number;
|
||||
@@ -7,32 +9,47 @@ interface Summary {
|
||||
|
||||
interface OrderSummaryCardsProps {
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
summary: Summary;
|
||||
}
|
||||
|
||||
export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) {
|
||||
export default function OrderSummaryCards({ isDark, locale, summary }: OrderSummaryCardsProps) {
|
||||
const cardClass = [
|
||||
'rounded-xl border p-3',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ');
|
||||
const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ');
|
||||
const labels =
|
||||
locale === 'en'
|
||||
? {
|
||||
total: 'Total Orders',
|
||||
pending: 'Pending',
|
||||
completed: 'Completed',
|
||||
failed: 'Closed/Failed',
|
||||
}
|
||||
: {
|
||||
total: '总订单',
|
||||
pending: '待支付',
|
||||
completed: '已完成',
|
||||
failed: '异常/关闭',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>总订单</div>
|
||||
<div className={labelClass}>{labels.total}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.total}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>待支付</div>
|
||||
<div className={labelClass}>{labels.pending}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.pending}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>已完成</div>
|
||||
<div className={labelClass}>{labels.completed}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.completed}</div>
|
||||
</div>
|
||||
<div className={cardClass}>
|
||||
<div className={labelClass}>异常/关闭</div>
|
||||
<div className={labelClass}>{labels.failed}</div>
|
||||
<div className="mt-1 text-xl font-semibold">{summary.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
|
||||
interface OrderTableProps {
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
orders: MyOrder[];
|
||||
}
|
||||
|
||||
export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) {
|
||||
export default function OrderTable({ isDark, locale, loading, error, orders }: OrderTableProps) {
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
empty: 'No matching orders found',
|
||||
orderId: 'Order ID',
|
||||
amount: 'Amount',
|
||||
payment: 'Payment Method',
|
||||
status: 'Status',
|
||||
createdAt: 'Created At',
|
||||
}
|
||||
: {
|
||||
empty: '暂无符合条件的订单记录',
|
||||
orderId: '订单号',
|
||||
amount: '金额',
|
||||
payment: '支付方式',
|
||||
status: '状态',
|
||||
createdAt: '创建时间',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
@@ -40,7 +61,7 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
暂无符合条件的订单记录
|
||||
{text.empty}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -50,11 +71,11 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>订单号</span>
|
||||
<span>金额</span>
|
||||
<span>支付方式</span>
|
||||
<span>状态</span>
|
||||
<span>创建时间</span>
|
||||
<span>{text.orderId}</span>
|
||||
<span>{text.amount}</span>
|
||||
<span>{text.payment}</span>
|
||||
<span>{text.status}</span>
|
||||
<span>{text.createdAt}</span>
|
||||
</div>
|
||||
<div className="space-y-2 md:space-y-0">
|
||||
{orders.map((order) => (
|
||||
@@ -67,19 +88,17 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
>
|
||||
<div className="font-medium">#{order.id.slice(0, 12)}</div>
|
||||
<div className="font-semibold">¥{order.amount.toFixed(2)}</div>
|
||||
<div>
|
||||
{getPaymentDisplayInfo(order.paymentType).channel}
|
||||
</div>
|
||||
<div>{getPaymentDisplayInfo(order.paymentType, locale).channel}</div>
|
||||
<div>
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{formatStatus(order.status)}
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt)}</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt, locale)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface PaginationBarProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions?: number[];
|
||||
locale?: Locale;
|
||||
isDark?: boolean;
|
||||
loading?: boolean;
|
||||
onPageChange: (newPage: number) => void;
|
||||
@@ -16,6 +19,7 @@ export default function PaginationBar({
|
||||
total,
|
||||
pageSize,
|
||||
pageSizeOptions = [20, 50, 100],
|
||||
locale,
|
||||
isDark = false,
|
||||
loading = false,
|
||||
onPageChange,
|
||||
@@ -30,17 +34,29 @@ export default function PaginationBar({
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-100',
|
||||
].join(' ');
|
||||
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
total: `Total ${total}${totalPages > 1 ? `, Page ${page} / ${totalPages}` : ''}`,
|
||||
perPage: 'Per page',
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
}
|
||||
: {
|
||||
total: `共 ${total} 条${totalPages > 1 ? `,第 ${page} / ${totalPages} 页` : ''}`,
|
||||
perPage: '每页',
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||
{/* 左侧:统计 + 每页大小 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
共 {total} 条{totalPages > 1 && `,第 ${page} / ${totalPages} 页`}
|
||||
</span>
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>{text.total}</span>
|
||||
|
||||
{onPageSizeChange && (
|
||||
<>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>每页</span>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{text.perPage}</span>
|
||||
{pageSizeOptions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
@@ -68,7 +84,6 @@ export default function PaginationBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:分页导航 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
@@ -85,7 +100,7 @@ export default function PaginationBar({
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className={navBtnClass(page <= 1)}
|
||||
>
|
||||
上一页
|
||||
{text.previous}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -93,7 +108,7 @@ export default function PaginationBar({
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className={navBtnClass(page >= totalPages)}
|
||||
>
|
||||
下一页
|
||||
{text.next}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface PayPageLayoutProps {
|
||||
isDark: boolean;
|
||||
@@ -8,6 +9,7 @@ interface PayPageLayoutProps {
|
||||
subtitle: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function PayPageLayout({
|
||||
@@ -18,6 +20,7 @@ export default function PayPageLayout({
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
locale = 'zh',
|
||||
}: PayPageLayoutProps) {
|
||||
const maxWidthClass = maxWidth === 'sm' ? 'max-w-lg' : maxWidth === 'lg' ? 'max-w-6xl' : '';
|
||||
|
||||
@@ -64,7 +67,7 @@ export default function PayPageLayout({
|
||||
isDark ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-50 text-indigo-700',
|
||||
].join(' ')}
|
||||
>
|
||||
Sub2API Secure Pay
|
||||
{locale === 'en' ? 'Sub2API Secure Pay' : 'Sub2API 安全支付'}
|
||||
</div>
|
||||
<h1
|
||||
className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta, getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
|
||||
export interface MethodLimitInfo {
|
||||
available: boolean;
|
||||
@@ -25,6 +26,7 @@ interface PaymentFormProps {
|
||||
dark?: boolean;
|
||||
pendingBlocked?: boolean;
|
||||
pendingCount?: number;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
|
||||
@@ -47,12 +49,12 @@ export default function PaymentForm({
|
||||
dark = false,
|
||||
pendingBlocked = false,
|
||||
pendingCount = 0,
|
||||
locale = 'zh',
|
||||
}: PaymentFormProps) {
|
||||
const [amount, setAmount] = useState<number | ''>('');
|
||||
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
||||
const [customAmount, setCustomAmount] = useState('');
|
||||
|
||||
// Reset paymentType when enabledPaymentTypes changes (e.g. after config loads)
|
||||
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
|
||||
? paymentType
|
||||
: enabledPaymentTypes[0] || 'stripe';
|
||||
@@ -107,7 +109,7 @@ export default function PaymentForm({
|
||||
if (iconType === 'alipay') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
{locale === 'en' ? 'A' : '支'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +146,6 @@ export default function PaymentForm({
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-4',
|
||||
@@ -152,22 +153,22 @@ export default function PaymentForm({
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
充值账户
|
||||
{locale === 'en' ? 'Recharge Account' : '充值账户'}
|
||||
</div>
|
||||
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{userName || `用户 #${userId}`}
|
||||
{userName || (locale === 'en' ? `User #${userId}` : `用户 #${userId}`)}
|
||||
</div>
|
||||
{userBalance !== undefined && (
|
||||
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
当前余额: <span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
|
||||
{locale === 'en' ? 'Current Balance:' : '当前余额:'}{' '}
|
||||
<span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Amount Selection */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
充值金额
|
||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
|
||||
@@ -189,10 +190,9 @@ export default function PaymentForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Amount */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
自定义金额
|
||||
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span
|
||||
@@ -223,23 +223,25 @@ export default function PaymentForm({
|
||||
!isValid &&
|
||||
(() => {
|
||||
const num = parseFloat(customAmount);
|
||||
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||
let msg = locale === 'en'
|
||||
? 'Amount must be within range and support up to 2 decimal places'
|
||||
: '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||
if (!isNaN(num)) {
|
||||
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
|
||||
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
|
||||
if (num < minAmount) msg = locale === 'en' ? `Minimum per transaction: ¥${minAmount}` : `单笔最低充值 ¥${minAmount}`;
|
||||
else if (num > effectiveMax) msg = locale === 'en' ? `Maximum per transaction: ¥${effectiveMax}` : `单笔最高充值 ¥${effectiveMax}`;
|
||||
}
|
||||
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
|
||||
})()}
|
||||
|
||||
{/* Payment Type — only show when multiple types available */}
|
||||
{enabledPaymentTypes.length > 1 && (
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
||||
支付方式
|
||||
{locale === 'en' ? 'Payment Method' : '支付方式'}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||
{enabledPaymentTypes.map((type) => {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
const displayInfo = getPaymentDisplayInfo(type, locale);
|
||||
const isSelected = effectivePaymentType === type;
|
||||
const limitInfo = methodLimits?.[type];
|
||||
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
|
||||
@@ -250,7 +252,7 @@ export default function PaymentForm({
|
||||
type="button"
|
||||
disabled={isUnavailable}
|
||||
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
|
||||
title={isUnavailable ? (locale === 'en' ? 'Daily limit reached, please use another payment method' : '今日充值额度已满,请使用其他支付方式') : undefined}
|
||||
className={[
|
||||
'relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
||||
isUnavailable
|
||||
@@ -267,14 +269,14 @@ export default function PaymentForm({
|
||||
<span className="flex items-center gap-2">
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
<span className="text-xl font-semibold tracking-tight">{displayInfo.channel || type}</span>
|
||||
{isUnavailable ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">今日额度已满</span>
|
||||
) : meta?.sublabel ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}</span>
|
||||
) : displayInfo.sublabel ? (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark ? (isSelected ? 'text-slate-300' : 'text-slate-400') : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
{displayInfo.sublabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
@@ -284,20 +286,20 @@ export default function PaymentForm({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前选中渠道额度不足时的提示 */}
|
||||
{(() => {
|
||||
const limitInfo = methodLimits?.[effectivePaymentType];
|
||||
if (!limitInfo || limitInfo.available) return null;
|
||||
return (
|
||||
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
||||
所选支付方式今日额度已满,请切换到其他支付方式
|
||||
{locale === 'en'
|
||||
? 'The selected payment method has reached today\'s limit. Please switch to another method.'
|
||||
: '所选支付方式今日额度已满,请切换到其他支付方式'}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee Detail */}
|
||||
{feeRate > 0 && selectedAmount > 0 && (
|
||||
<div
|
||||
className={[
|
||||
@@ -306,26 +308,25 @@ export default function PaymentForm({
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>充值金额</span>
|
||||
<span>{locale === 'en' ? 'Recharge Amount' : '充值金额'}</span>
|
||||
<span>¥{selectedAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span>手续费({feeRate}%)</span>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span>{locale === 'en' ? `Fee (${feeRate}%)` : `手续费(${feeRate}%)`}</span>
|
||||
<span>¥{feeAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
|
||||
'mt-1.5 flex items-center justify-between border-t pt-1.5 font-medium',
|
||||
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>实付金额</span>
|
||||
<span>{locale === 'en' ? 'Amount to Pay' : '实付金额'}</span>
|
||||
<span>¥{payAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending order limit warning */}
|
||||
{pendingBlocked && (
|
||||
<div
|
||||
className={[
|
||||
@@ -335,11 +336,12 @@ export default function PaymentForm({
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
您有 {pendingCount} 个待支付订单,请先完成或取消后再充值
|
||||
{locale === 'en'
|
||||
? `You have ${pendingCount} pending orders. Please complete or cancel them before recharging.`
|
||||
: `您有 ${pendingCount} 个待支付订单,请先完成或取消后再充值`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || loading || pendingBlocked}
|
||||
@@ -352,10 +354,16 @@ export default function PaymentForm({
|
||||
}`}
|
||||
>
|
||||
{loading
|
||||
? '处理中...'
|
||||
? locale === 'en'
|
||||
? 'Processing...'
|
||||
: '处理中...'
|
||||
: pendingBlocked
|
||||
? '待支付订单过多'
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
? locale === 'en'
|
||||
? 'Too many pending orders'
|
||||
: '待支付订单过多'
|
||||
: locale === 'en'
|
||||
? `Recharge Now ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import {
|
||||
isStripeType,
|
||||
getPaymentMeta,
|
||||
@@ -26,16 +27,9 @@ interface PaymentQRCodeProps {
|
||||
dark?: boolean;
|
||||
isEmbedded?: boolean;
|
||||
isMobile?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const TEXT_EXPIRED = '订单已超时';
|
||||
const TEXT_REMAINING = '剩余支付时间';
|
||||
const TEXT_GO_PAY = '点击前往支付';
|
||||
const TEXT_SCAN_PAY = '请使用支付应用扫码支付';
|
||||
const TEXT_BACK = '返回';
|
||||
const TEXT_CANCEL_ORDER = '取消订单';
|
||||
const TEXT_H5_HINT = '支付完成后请返回此页面,系统将自动确认';
|
||||
|
||||
export default function PaymentQRCode({
|
||||
orderId,
|
||||
token,
|
||||
@@ -52,6 +46,7 @@ export default function PaymentQRCode({
|
||||
dark = false,
|
||||
isEmbedded = false,
|
||||
isMobile = false,
|
||||
locale = 'zh',
|
||||
}: PaymentQRCodeProps) {
|
||||
const displayAmount = payAmountProp ?? amount;
|
||||
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
|
||||
@@ -63,7 +58,6 @@ export default function PaymentQRCode({
|
||||
const [cancelBlocked, setCancelBlocked] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Stripe Payment Element state
|
||||
const [stripeLoaded, setStripeLoaded] = useState(false);
|
||||
const [stripeSubmitting, setStripeSubmitting] = useState(false);
|
||||
const [stripeError, setStripeError] = useState('');
|
||||
@@ -72,12 +66,41 @@ export default function PaymentQRCode({
|
||||
stripe: import('@stripe/stripe-js').Stripe;
|
||||
elements: import('@stripe/stripe-js').StripeElements;
|
||||
} | null>(null);
|
||||
// Track selected payment method in Payment Element (for embedded popup decision)
|
||||
const [stripePaymentMethod, setStripePaymentMethod] = useState('card');
|
||||
const [popupBlocked, setPopupBlocked] = useState(false);
|
||||
const paymentMethodListenerAdded = useRef(false);
|
||||
|
||||
// PC 端有二维码时优先展示二维码;仅移动端或无二维码时才跳转
|
||||
const t = {
|
||||
expired: locale === 'en' ? 'Order Expired' : '订单已超时',
|
||||
remaining: locale === 'en' ? 'Time Remaining' : '剩余支付时间',
|
||||
scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付',
|
||||
back: locale === 'en' ? 'Back' : '返回',
|
||||
cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单',
|
||||
h5Hint: locale === 'en' ? 'After payment, please return to this page. The system will confirm automatically.' : '支付完成后请返回此页面,系统将自动确认',
|
||||
paid: locale === 'en' ? 'Order Paid' : '订单已支付',
|
||||
paidCancelBlocked:
|
||||
locale === 'en' ? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.' : '该订单已支付完成,无法取消。充值将自动到账。',
|
||||
backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值',
|
||||
credited: locale === 'en' ? 'Credited ¥' : '到账 ¥',
|
||||
stripeLoadFailed: locale === 'en' ? 'Failed to load payment component. Please refresh and try again.' : '支付组件加载失败,请刷新页面重试',
|
||||
initFailed: locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试',
|
||||
loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...',
|
||||
payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试',
|
||||
successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...',
|
||||
processing: locale === 'en' ? 'Processing...' : '处理中...',
|
||||
payNow: locale === 'en' ? 'Pay' : '支付',
|
||||
popupBlocked:
|
||||
locale === 'en' ? 'Popup was blocked by your browser. Please allow popups for this site and try again.' : '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
|
||||
redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到',
|
||||
redirectingSuffix: locale === 'en' ? '...' : '...',
|
||||
notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往',
|
||||
goPaySuffix: locale === 'en' ? '' : '',
|
||||
gotoPrefix: locale === 'en' ? 'Open ' : '前往',
|
||||
gotoSuffix: locale === 'en' ? ' to pay' : '支付',
|
||||
openScanPrefix: locale === 'en' ? 'Open ' : '请打开',
|
||||
openScanSuffix: locale === 'en' ? ' and scan to complete payment' : '扫一扫完成支付',
|
||||
};
|
||||
|
||||
const shouldAutoRedirect = !expired && !isStripeType(paymentType) && !!payUrl && (isMobile || !qrCode);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,7 +151,6 @@ export default function PaymentQRCode({
|
||||
};
|
||||
}, [qrPayload]);
|
||||
|
||||
// Initialize Stripe Payment Element
|
||||
const isStripe = isStripeType(paymentType);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -139,7 +161,7 @@ export default function PaymentQRCode({
|
||||
loadStripe(stripePublishableKey).then((stripe) => {
|
||||
if (cancelled) return;
|
||||
if (!stripe) {
|
||||
setStripeError('支付组件加载失败,请刷新页面重试');
|
||||
setStripeError(t.stripeLoadFailed);
|
||||
setStripeLoaded(true);
|
||||
return;
|
||||
}
|
||||
@@ -160,9 +182,8 @@ export default function PaymentQRCode({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isStripe, clientSecret, stripePublishableKey, dark]);
|
||||
}, [isStripe, clientSecret, stripePublishableKey, dark, t.stripeLoadFailed]);
|
||||
|
||||
// Mount Payment Element when container is available
|
||||
const stripeContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node || !stripeLib) return;
|
||||
@@ -188,7 +209,6 @@ export default function PaymentQRCode({
|
||||
const handleStripeSubmit = async () => {
|
||||
if (!stripeLib || stripeSubmitting) return;
|
||||
|
||||
// In embedded mode, Alipay redirects to a page with X-Frame-Options that breaks iframe
|
||||
if (isEmbedded && stripePaymentMethod === 'alipay') {
|
||||
handleOpenPopup();
|
||||
return;
|
||||
@@ -203,6 +223,9 @@ export default function PaymentQRCode({
|
||||
returnUrl.search = '';
|
||||
returnUrl.searchParams.set('order_id', orderId);
|
||||
returnUrl.searchParams.set('status', 'success');
|
||||
if (locale === 'en') {
|
||||
returnUrl.searchParams.set('lang', 'en');
|
||||
}
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
@@ -213,20 +236,17 @@ export default function PaymentQRCode({
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setStripeError(error.message || '支付失败,请重试');
|
||||
setStripeError(error.message || t.payFailed);
|
||||
setStripeSubmitting(false);
|
||||
} else {
|
||||
// Payment succeeded (or no redirect needed)
|
||||
setStripeSuccess(true);
|
||||
setStripeSubmitting(false);
|
||||
// Polling will pick up the status change
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPopup = () => {
|
||||
if (!clientSecret || !stripePublishableKey) return;
|
||||
setPopupBlocked(false);
|
||||
// Only pass display params in URL — sensitive data sent via postMessage
|
||||
const popupUrl = new URL(window.location.href);
|
||||
popupUrl.pathname = '/pay/stripe-popup';
|
||||
popupUrl.search = '';
|
||||
@@ -234,13 +254,15 @@ export default function PaymentQRCode({
|
||||
popupUrl.searchParams.set('amount', String(amount));
|
||||
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
|
||||
popupUrl.searchParams.set('method', stripePaymentMethod);
|
||||
if (locale === 'en') {
|
||||
popupUrl.searchParams.set('lang', 'en');
|
||||
}
|
||||
|
||||
const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes');
|
||||
if (!popup || popup.closed) {
|
||||
setPopupBlocked(true);
|
||||
return;
|
||||
}
|
||||
// Send sensitive data via postMessage after popup loads
|
||||
const onReady = (event: MessageEvent) => {
|
||||
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
|
||||
window.removeEventListener('message', onReady);
|
||||
@@ -263,7 +285,7 @@ export default function PaymentQRCode({
|
||||
const diff = expiry - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeLeft(TEXT_EXPIRED);
|
||||
setTimeLeft(t.expired);
|
||||
setTimeLeftSeconds(0);
|
||||
setExpired(true);
|
||||
return;
|
||||
@@ -279,7 +301,7 @@ export default function PaymentQRCode({
|
||||
updateTimer();
|
||||
const timer = setInterval(updateTimer, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [expiresAt]);
|
||||
}, [expiresAt, t.expired]);
|
||||
|
||||
const pollStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -291,7 +313,6 @@ export default function PaymentQRCode({
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore polling errors
|
||||
}
|
||||
}, [orderId, onStatusChange]);
|
||||
|
||||
@@ -305,7 +326,6 @@ export default function PaymentQRCode({
|
||||
const handleCancel = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
// 先检查当前订单状态
|
||||
const res = await fetch(`/api/orders/${orderId}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
@@ -331,28 +351,27 @@ export default function PaymentQRCode({
|
||||
await pollStatus();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const meta = getPaymentMeta(paymentType || 'alipay');
|
||||
const iconSrc = getPaymentIconSrc(paymentType || 'alipay');
|
||||
const channelLabel = getPaymentChannelLabel(paymentType || 'alipay');
|
||||
const channelLabel = getPaymentChannelLabel(paymentType || 'alipay', locale);
|
||||
const iconBgClass = meta.iconBg;
|
||||
|
||||
if (cancelBlocked) {
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4 py-8">
|
||||
<div className="text-6xl text-green-600">{'✓'}</div>
|
||||
<h2 className="text-xl font-bold text-green-600">{'订单已支付'}</h2>
|
||||
<h2 className="text-xl font-bold text-green-600">{t.paid}</h2>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{'该订单已支付完成,无法取消。充值将自动到账。'}
|
||||
{t.paidCancelBlocked}
|
||||
</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{'返回充值'}
|
||||
{t.backToRecharge}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -367,11 +386,12 @@ export default function PaymentQRCode({
|
||||
</div>
|
||||
{hasFeeDiff && (
|
||||
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
到账 ¥{amount.toFixed(2)}
|
||||
{t.credited}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : !expired && timeLeftSeconds <= 60 ? 'text-red-500 animate-pulse' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
|
||||
{expired ? t.expired : `${t.remaining}: ${timeLeft}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -387,14 +407,14 @@ export default function PaymentQRCode({
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
支付初始化失败,请返回重试
|
||||
{t.initFailed}
|
||||
</p>
|
||||
</div>
|
||||
) : !stripeLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在加载支付表单...
|
||||
{t.loadingForm}
|
||||
</span>
|
||||
</div>
|
||||
) : stripeError && !stripeLib ? (
|
||||
@@ -420,7 +440,7 @@ export default function PaymentQRCode({
|
||||
<div className="text-center">
|
||||
<div className="text-4xl text-green-600">{'✓'}</div>
|
||||
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
支付成功,正在处理订单...
|
||||
{t.successProcessing}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -431,17 +451,17 @@ export default function PaymentQRCode({
|
||||
className={[
|
||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||
stripeSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
? 'cursor-not-allowed bg-gray-400'
|
||||
: meta.buttonClass,
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeSubmitting ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
处理中...
|
||||
{t.processing}
|
||||
</span>
|
||||
) : (
|
||||
`支付 ¥${amount.toFixed(2)}`
|
||||
`${t.payNow} ¥${amount.toFixed(2)}`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -454,7 +474,7 @@ export default function PaymentQRCode({
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
弹出窗口被浏览器拦截,请允许本站弹出窗口后重试
|
||||
{t.popupBlocked}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -465,7 +485,7 @@ export default function PaymentQRCode({
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`} style={{ borderColor: meta.color, borderTopColor: 'transparent' }} />
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在跳转到{channelLabel}...
|
||||
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -475,10 +495,10 @@ export default function PaymentQRCode({
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${meta.buttonClass}`}
|
||||
>
|
||||
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
|
||||
{redirected ? `未跳转?点击前往${channelLabel}` : `前往${channelLabel}支付`}
|
||||
{redirected ? `${t.notRedirectedPrefix}${channelLabel}` : `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
|
||||
</a>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{TEXT_H5_HINT}
|
||||
{t.h5Hint}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -512,13 +532,13 @@ export default function PaymentQRCode({
|
||||
dark ? 'border-slate-700' : 'border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.scanPay}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{`请打开${channelLabel}扫一扫完成支付`}
|
||||
{`${t.openScanPrefix}${channelLabel}${t.openScanSuffix}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -535,7 +555,7 @@ export default function PaymentQRCode({
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{TEXT_BACK}
|
||||
{t.back}
|
||||
</button>
|
||||
{!expired && token && (
|
||||
<button
|
||||
@@ -547,7 +567,7 @@ export default function PaymentQRCode({
|
||||
: 'border-red-300 text-red-600 hover:bg-red-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{TEXT_CANCEL_ORDER}
|
||||
{t.cancelOrder}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface DailyData {
|
||||
date: string;
|
||||
@@ -11,6 +12,7 @@ interface DailyData {
|
||||
interface DailyChartProps {
|
||||
data: DailyData[];
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
@@ -34,11 +36,17 @@ function CustomTooltip({
|
||||
payload,
|
||||
label,
|
||||
dark,
|
||||
currency,
|
||||
amountLabel,
|
||||
countLabel,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayload[];
|
||||
label?: string;
|
||||
dark?: boolean;
|
||||
currency: string;
|
||||
amountLabel: string;
|
||||
countLabel: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
@@ -51,16 +59,20 @@ function CustomTooltip({
|
||||
<p className={['mb-1 text-xs', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{label}</p>
|
||||
{payload.map((p) => (
|
||||
<p key={p.dataKey}>
|
||||
{p.dataKey === 'amount' ? '金额' : '笔数'}:{' '}
|
||||
{p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}
|
||||
{p.dataKey === 'amount' ? amountLabel : countLabel}:{' '}
|
||||
{p.dataKey === 'amount' ? `${currency}${p.value.toLocaleString()}` : p.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
// Auto-calculate tick interval: show ~10-15 labels max
|
||||
export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const chartTitle = locale === 'en' ? 'Daily Recharge Trend' : '每日充值趋势';
|
||||
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
|
||||
const amountLabel = locale === 'en' ? 'Amount' : '金额';
|
||||
const countLabel = locale === 'en' ? 'Orders' : '笔数';
|
||||
const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0;
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
@@ -71,9 +83,9 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
每日充值趋势
|
||||
{chartTitle}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +101,7 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
每日充值趋势
|
||||
{chartTitle}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
|
||||
@@ -109,7 +121,7 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
tickLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip dark={dark} />} />
|
||||
<Tooltip content={<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface Summary {
|
||||
today: { amount: number; orderCount: number; paidCount: number };
|
||||
total: { amount: number; orderCount: number; paidCount: number };
|
||||
@@ -10,16 +12,18 @@ interface Summary {
|
||||
interface DashboardStatsProps {
|
||||
summary: Summary;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function DashboardStats({ summary, dark }: DashboardStatsProps) {
|
||||
export default function DashboardStats({ summary, dark, locale = 'zh' }: DashboardStatsProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const cards = [
|
||||
{ label: '今日充值', value: `¥${summary.today.amount.toLocaleString()}`, accent: true },
|
||||
{ label: '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` },
|
||||
{ label: '累计充值', value: `¥${summary.total.amount.toLocaleString()}`, accent: true },
|
||||
{ label: '累计订单', value: String(summary.total.paidCount) },
|
||||
{ label: '成功率', value: `${summary.successRate}%` },
|
||||
{ label: '平均充值', value: `¥${summary.avgAmount.toFixed(2)}` },
|
||||
{ label: locale === 'en' ? 'Today Recharge' : '今日充值', value: `${currency}${summary.today.amount.toLocaleString()}`, accent: true },
|
||||
{ label: locale === 'en' ? 'Today Orders' : '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` },
|
||||
{ label: locale === 'en' ? 'Total Recharge' : '累计充值', value: `${currency}${summary.total.amount.toLocaleString()}`, accent: true },
|
||||
{ label: locale === 'en' ? 'Paid Orders' : '累计订单', value: String(summary.total.paidCount) },
|
||||
{ label: locale === 'en' ? 'Success Rate' : '成功率', value: `${summary.successRate}%` },
|
||||
{ label: locale === 'en' ? 'Average Amount' : '平均充值', value: `${currency}${summary.avgAmount.toFixed(2)}` },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface LeaderboardEntry {
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
@@ -11,6 +13,7 @@ interface LeaderboardEntry {
|
||||
interface LeaderboardProps {
|
||||
data: LeaderboardEntry[];
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const RANK_STYLES: Record<number, { light: string; dark: string }> = {
|
||||
@@ -19,7 +22,13 @@ const RANK_STYLES: Record<number, { light: string; dark: string }> = {
|
||||
3: { light: 'bg-orange-100 text-orange-700', dark: 'bg-orange-500/20 text-orange-300' },
|
||||
};
|
||||
|
||||
export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
export default function Leaderboard({ data, dark, locale = 'zh' }: LeaderboardProps) {
|
||||
const title = locale === 'en' ? 'Recharge Leaderboard (Top 10)' : '充值排行榜 (Top 10)';
|
||||
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
|
||||
const userLabel = locale === 'en' ? 'User' : '用户';
|
||||
const amountLabel = locale === 'en' ? 'Total Amount' : '累计金额';
|
||||
const orderCountLabel = locale === 'en' ? 'Orders' : '订单数';
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
const tdCls = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-300' : 'text-slate-700'}`;
|
||||
const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
@@ -33,9 +42,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
充值排行榜 (Top 10)
|
||||
{title}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,16 +57,16 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['px-6 pt-5 pb-2 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
充值排行榜 (Top 10)
|
||||
{title}
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
||||
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
|
||||
<tr>
|
||||
<th className={thCls}>#</th>
|
||||
<th className={thCls}>用户</th>
|
||||
<th className={thCls}>累计金额</th>
|
||||
<th className={thCls}>订单数</th>
|
||||
<th className={thCls}>{userLabel}</th>
|
||||
<th className={thCls}>{amountLabel}</th>
|
||||
<th className={thCls}>{orderCountLabel}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200'}`}>
|
||||
@@ -88,7 +97,7 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
||||
>
|
||||
¥{entry.totalAmount.toLocaleString()}
|
||||
{currency}{entry.totalAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className={tdMuted}>{entry.orderCount}</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
import { getPaymentDisplayInfo, formatCreatedAt } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
@@ -43,9 +44,83 @@ interface OrderDetailProps {
|
||||
};
|
||||
onClose: () => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) {
|
||||
export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: OrderDetailProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text = locale === 'en'
|
||||
? {
|
||||
title: 'Order Details',
|
||||
auditLogs: 'Audit Logs',
|
||||
operator: 'Operator',
|
||||
emptyLogs: 'No logs',
|
||||
close: 'Close',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
orderId: 'Order ID',
|
||||
userId: 'User ID',
|
||||
userName: 'Username',
|
||||
email: 'Email',
|
||||
amount: 'Amount',
|
||||
status: 'Status',
|
||||
paymentSuccess: 'Payment Success',
|
||||
rechargeSuccess: 'Recharge Success',
|
||||
rechargeStatus: 'Recharge Status',
|
||||
paymentChannel: 'Payment Channel',
|
||||
provider: 'Provider',
|
||||
rechargeCode: 'Recharge Code',
|
||||
paymentTradeNo: 'Payment Trade No.',
|
||||
clientIp: 'Client IP',
|
||||
sourceHost: 'Source Host',
|
||||
sourcePage: 'Source Page',
|
||||
createdAt: 'Created At',
|
||||
expiresAt: 'Expires At',
|
||||
paidAt: 'Paid At',
|
||||
completedAt: 'Completed At',
|
||||
failedAt: 'Failed At',
|
||||
failedReason: 'Failure Reason',
|
||||
refundAmount: 'Refund Amount',
|
||||
refundReason: 'Refund Reason',
|
||||
refundAt: 'Refunded At',
|
||||
forceRefund: 'Force Refund',
|
||||
}
|
||||
: {
|
||||
title: '订单详情',
|
||||
auditLogs: '审计日志',
|
||||
operator: '操作者',
|
||||
emptyLogs: '暂无日志',
|
||||
close: '关闭',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
orderId: '订单号',
|
||||
userId: '用户ID',
|
||||
userName: '用户名',
|
||||
email: '邮箱',
|
||||
amount: '金额',
|
||||
status: '状态',
|
||||
paymentSuccess: '支付成功',
|
||||
rechargeSuccess: '充值成功',
|
||||
rechargeStatus: '充值状态',
|
||||
paymentChannel: '支付渠道',
|
||||
provider: '提供商',
|
||||
rechargeCode: '充值码',
|
||||
paymentTradeNo: '支付单号',
|
||||
clientIp: '客户端IP',
|
||||
sourceHost: '来源域名',
|
||||
sourcePage: '来源页面',
|
||||
createdAt: '创建时间',
|
||||
expiresAt: '过期时间',
|
||||
paidAt: '支付时间',
|
||||
completedAt: '完成时间',
|
||||
failedAt: '失败时间',
|
||||
failedReason: '失败原因',
|
||||
refundAmount: '退款金额',
|
||||
refundReason: '退款原因',
|
||||
refundAt: '退款时间',
|
||||
forceRefund: '强制退款',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
@@ -54,37 +129,39 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const paymentInfo = getPaymentDisplayInfo(order.paymentType, locale);
|
||||
|
||||
const fields = [
|
||||
{ label: '订单号', value: order.id },
|
||||
{ label: '用户ID', value: order.userId },
|
||||
{ label: '用户名', value: order.userName || '-' },
|
||||
{ label: '邮箱', value: order.userEmail || '-' },
|
||||
{ label: '金额', value: `¥${order.amount.toFixed(2)}` },
|
||||
{ label: '状态', value: order.status },
|
||||
{ label: '支付成功', value: order.paymentSuccess ? 'yes' : 'no' },
|
||||
{ label: '充值成功', value: order.rechargeSuccess ? 'yes' : 'no' },
|
||||
{ label: '充值状态', value: order.rechargeStatus || '-' },
|
||||
{ label: '支付渠道', value: getPaymentDisplayInfo(order.paymentType).channel },
|
||||
{ label: '提供商', value: getPaymentDisplayInfo(order.paymentType).provider || '-' },
|
||||
{ label: '充值码', value: order.rechargeCode },
|
||||
{ label: '支付单号', value: order.paymentTradeNo || '-' },
|
||||
{ label: '客户端IP', value: order.clientIp || '-' },
|
||||
{ label: '来源域名', value: order.srcHost || '-' },
|
||||
{ label: '来源页面', value: order.srcUrl || '-' },
|
||||
{ label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') },
|
||||
{ label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') },
|
||||
{ label: '支付时间', value: order.paidAt ? new Date(order.paidAt).toLocaleString('zh-CN') : '-' },
|
||||
{ label: '完成时间', value: order.completedAt ? new Date(order.completedAt).toLocaleString('zh-CN') : '-' },
|
||||
{ label: '失败时间', value: order.failedAt ? new Date(order.failedAt).toLocaleString('zh-CN') : '-' },
|
||||
{ label: '失败原因', value: order.failedReason || '-' },
|
||||
{ label: text.orderId, value: order.id },
|
||||
{ label: text.userId, value: order.userId },
|
||||
{ label: text.userName, value: order.userName || '-' },
|
||||
{ label: text.email, value: order.userEmail || '-' },
|
||||
{ label: text.amount, value: `${currency}${order.amount.toFixed(2)}` },
|
||||
{ label: text.status, value: order.status },
|
||||
{ label: text.paymentSuccess, value: order.paymentSuccess ? text.yes : text.no },
|
||||
{ label: text.rechargeSuccess, value: order.rechargeSuccess ? text.yes : text.no },
|
||||
{ label: text.rechargeStatus, value: order.rechargeStatus || '-' },
|
||||
{ label: text.paymentChannel, value: paymentInfo.channel },
|
||||
{ label: text.provider, value: paymentInfo.provider || '-' },
|
||||
{ label: text.rechargeCode, value: order.rechargeCode },
|
||||
{ label: text.paymentTradeNo, value: order.paymentTradeNo || '-' },
|
||||
{ label: text.clientIp, value: order.clientIp || '-' },
|
||||
{ label: text.sourceHost, value: order.srcHost || '-' },
|
||||
{ label: text.sourcePage, value: order.srcUrl || '-' },
|
||||
{ label: text.createdAt, value: formatCreatedAt(order.createdAt, locale) },
|
||||
{ label: text.expiresAt, value: formatCreatedAt(order.expiresAt, locale) },
|
||||
{ label: text.paidAt, value: order.paidAt ? formatCreatedAt(order.paidAt, locale) : '-' },
|
||||
{ label: text.completedAt, value: order.completedAt ? formatCreatedAt(order.completedAt, locale) : '-' },
|
||||
{ label: text.failedAt, value: order.failedAt ? formatCreatedAt(order.failedAt, locale) : '-' },
|
||||
{ label: text.failedReason, value: order.failedReason || '-' },
|
||||
];
|
||||
|
||||
if (order.refundAmount) {
|
||||
fields.push(
|
||||
{ label: '退款金额', value: `¥${order.refundAmount.toFixed(2)}` },
|
||||
{ label: '退款原因', value: order.refundReason || '-' },
|
||||
{ label: '退款时间', value: order.refundAt ? new Date(order.refundAt).toLocaleString('zh-CN') : '-' },
|
||||
{ label: '强制退款', value: order.forceRefund ? '是' : '否' },
|
||||
{ label: text.refundAmount, value: `${currency}${order.refundAmount.toFixed(2)}` },
|
||||
{ label: text.refundReason, value: order.refundReason || '-' },
|
||||
{ label: text.refundAt, value: order.refundAt ? formatCreatedAt(order.refundAt, locale) : '-' },
|
||||
{ label: text.forceRefund, value: order.forceRefund ? text.yes : text.no },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +172,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">订单详情</h3>
|
||||
<h3 className="text-lg font-bold">{text.title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}
|
||||
@@ -115,7 +192,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
|
||||
{/* Audit Logs */}
|
||||
<div className="mt-6">
|
||||
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>审计日志</h4>
|
||||
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>{text.auditLogs}</h4>
|
||||
<div className="space-y-2">
|
||||
{order.auditLogs.map((log) => (
|
||||
<div
|
||||
@@ -125,7 +202,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{log.action}</span>
|
||||
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||
{formatCreatedAt(log.createdAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
{log.detail && (
|
||||
@@ -135,13 +212,13 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
)}
|
||||
{log.operator && (
|
||||
<div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
操作者: {log.operator}
|
||||
{text.operator}: {log.operator}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{order.auditLogs.length === 0 && (
|
||||
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>暂无日志</div>
|
||||
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{text.emptyLogs}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,7 +227,7 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
onClick={onClose}
|
||||
className={`mt-6 w-full rounded-lg border py-2 text-sm ${dark ? 'border-slate-600 text-slate-300 hover:bg-slate-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
关闭
|
||||
{text.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
import { getPaymentDisplayInfo, formatStatus, formatCreatedAt } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
@@ -26,22 +27,43 @@ interface OrderTableProps {
|
||||
onCancel: (orderId: string) => void;
|
||||
onViewDetail: (orderId: string) => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; light: string; dark: string }> = {
|
||||
PENDING: { label: '待支付', light: 'bg-yellow-100 text-yellow-800', dark: 'bg-yellow-500/20 text-yellow-300' },
|
||||
PAID: { label: '已支付', light: 'bg-blue-100 text-blue-800', dark: 'bg-blue-500/20 text-blue-300' },
|
||||
RECHARGING: { label: '充值中', light: 'bg-blue-100 text-blue-800', dark: 'bg-blue-500/20 text-blue-300' },
|
||||
COMPLETED: { label: '已完成', light: 'bg-green-100 text-green-800', dark: 'bg-green-500/20 text-green-300' },
|
||||
EXPIRED: { label: '已超时', light: 'bg-gray-100 text-gray-800', dark: 'bg-slate-600/30 text-slate-400' },
|
||||
CANCELLED: { label: '已取消', light: 'bg-gray-100 text-gray-800', dark: 'bg-slate-600/30 text-slate-400' },
|
||||
FAILED: { label: '充值失败', light: 'bg-red-100 text-red-800', dark: 'bg-red-500/20 text-red-300' },
|
||||
REFUNDING: { label: '退款中', light: 'bg-orange-100 text-orange-800', dark: 'bg-orange-500/20 text-orange-300' },
|
||||
REFUNDED: { label: '已退款', light: 'bg-purple-100 text-purple-800', dark: 'bg-purple-500/20 text-purple-300' },
|
||||
REFUND_FAILED: { label: '退款失败', light: 'bg-red-100 text-red-800', dark: 'bg-red-500/20 text-red-300' },
|
||||
};
|
||||
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark, locale = 'zh' }: OrderTableProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text = locale === 'en'
|
||||
? {
|
||||
orderId: 'Order ID',
|
||||
userName: 'Username',
|
||||
email: 'Email',
|
||||
notes: 'Notes',
|
||||
amount: 'Amount',
|
||||
status: 'Status',
|
||||
paymentMethod: 'Payment',
|
||||
source: 'Source',
|
||||
createdAt: 'Created At',
|
||||
actions: 'Actions',
|
||||
retry: 'Retry',
|
||||
cancel: 'Cancel',
|
||||
empty: 'No orders',
|
||||
}
|
||||
: {
|
||||
orderId: '订单号',
|
||||
userName: '用户名',
|
||||
email: '邮箱',
|
||||
notes: '备注',
|
||||
amount: '金额',
|
||||
status: '状态',
|
||||
paymentMethod: '支付方式',
|
||||
source: '来源',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作',
|
||||
retry: '重试',
|
||||
cancel: '取消',
|
||||
empty: '暂无订单',
|
||||
};
|
||||
|
||||
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark }: OrderTableProps) {
|
||||
const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
|
||||
@@ -50,24 +72,50 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
||||
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
|
||||
<tr>
|
||||
<th className={thCls}>订单号</th>
|
||||
<th className={thCls}>用户名</th>
|
||||
<th className={thCls}>邮箱</th>
|
||||
<th className={thCls}>备注</th>
|
||||
<th className={thCls}>金额</th>
|
||||
<th className={thCls}>状态</th>
|
||||
<th className={thCls}>支付方式</th>
|
||||
<th className={thCls}>来源</th>
|
||||
<th className={thCls}>创建时间</th>
|
||||
<th className={thCls}>操作</th>
|
||||
<th className={thCls}>{text.orderId}</th>
|
||||
<th className={thCls}>{text.userName}</th>
|
||||
<th className={thCls}>{text.email}</th>
|
||||
<th className={thCls}>{text.notes}</th>
|
||||
<th className={thCls}>{text.amount}</th>
|
||||
<th className={thCls}>{text.status}</th>
|
||||
<th className={thCls}>{text.paymentMethod}</th>
|
||||
<th className={thCls}>{text.source}</th>
|
||||
<th className={thCls}>{text.createdAt}</th>
|
||||
<th className={thCls}>{text.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200 bg-white'}`}>
|
||||
{orders.map((order) => {
|
||||
const statusInfo = STATUS_LABELS[order.status] || {
|
||||
label: order.status,
|
||||
light: 'bg-gray-100 text-gray-800',
|
||||
dark: 'bg-slate-600/30 text-slate-400',
|
||||
const statusInfo = {
|
||||
label: formatStatus(order.status, locale),
|
||||
light:
|
||||
order.status === 'FAILED' || order.status === 'REFUND_FAILED'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: order.status === 'REFUNDED'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: order.status === 'REFUNDING'
|
||||
? 'bg-orange-100 text-orange-800'
|
||||
: order.status === 'COMPLETED'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: order.status === 'PAID' || order.status === 'RECHARGING'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: order.status === 'PENDING'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800',
|
||||
dark:
|
||||
order.status === 'FAILED' || order.status === 'REFUND_FAILED'
|
||||
? 'bg-red-500/20 text-red-300'
|
||||
: order.status === 'REFUNDED'
|
||||
? 'bg-purple-500/20 text-purple-300'
|
||||
: order.status === 'REFUNDING'
|
||||
? 'bg-orange-500/20 text-orange-300'
|
||||
: order.status === 'COMPLETED'
|
||||
? 'bg-green-500/20 text-green-300'
|
||||
: order.status === 'PAID' || order.status === 'RECHARGING'
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: order.status === 'PENDING'
|
||||
? 'bg-yellow-500/20 text-yellow-300'
|
||||
: 'bg-slate-600/30 text-slate-400',
|
||||
};
|
||||
return (
|
||||
<tr key={order.id} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
||||
@@ -85,7 +133,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
||||
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
|
||||
¥{order.amount.toFixed(2)}
|
||||
{currency}{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
@@ -96,7 +144,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType, locale);
|
||||
return (
|
||||
<>
|
||||
{channel}
|
||||
@@ -110,7 +158,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
})()}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.srcHost || '-'}</td>
|
||||
<td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td>
|
||||
<td className={tdMuted}>{formatCreatedAt(order.createdAt, locale)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<div className="flex gap-1">
|
||||
{order.rechargeRetryable && (
|
||||
@@ -118,7 +166,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
onClick={() => onRetry(order.id)}
|
||||
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-blue-500/20 text-blue-300 hover:bg-blue-500/30' : 'bg-blue-100 text-blue-700 hover:bg-blue-200'}`}
|
||||
>
|
||||
重试
|
||||
{text.retry}
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'PENDING' && (
|
||||
@@ -126,7 +174,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
onClick={() => onCancel(order.id)}
|
||||
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-red-500/20 text-red-300 hover:bg-red-500/30' : 'bg-red-100 text-red-700 hover:bg-red-200'}`}
|
||||
>
|
||||
取消
|
||||
{text.cancel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,7 +185,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 && (
|
||||
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>暂无订单</div>
|
||||
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>{text.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentTypeLabel, getPaymentMeta } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface PaymentMethod {
|
||||
paymentType: string;
|
||||
@@ -12,9 +13,14 @@ interface PaymentMethod {
|
||||
interface PaymentMethodChartProps {
|
||||
data: PaymentMethod[];
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) {
|
||||
export default function PaymentMethodChart({ data, dark, locale = 'zh' }: PaymentMethodChartProps) {
|
||||
const title = locale === 'en' ? 'Payment Method Distribution' : '支付方式分布';
|
||||
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
@@ -24,9 +30,9 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
支付方式分布
|
||||
{title}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,18 +45,18 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
支付方式分布
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{data.map((method) => {
|
||||
const meta = getPaymentMeta(method.paymentType);
|
||||
const label = getPaymentTypeLabel(method.paymentType);
|
||||
const label = getPaymentTypeLabel(method.paymentType, locale);
|
||||
return (
|
||||
<div key={method.paymentType}>
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className={dark ? 'text-slate-300' : 'text-slate-700'}>{label}</span>
|
||||
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
¥{method.amount.toLocaleString()} · {method.percentage}%
|
||||
{currency}{method.amount.toLocaleString()} · {method.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface RefundDialogProps {
|
||||
orderId: string;
|
||||
@@ -10,6 +11,7 @@ interface RefundDialogProps {
|
||||
warning?: string;
|
||||
requireForce?: boolean;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function RefundDialog({
|
||||
@@ -20,11 +22,38 @@ export default function RefundDialog({
|
||||
warning,
|
||||
requireForce,
|
||||
dark = false,
|
||||
locale = 'zh',
|
||||
}: RefundDialogProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [force, setForce] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
title: 'Confirm Refund',
|
||||
orderId: 'Order ID',
|
||||
amount: 'Refund Amount',
|
||||
reason: 'Refund Reason',
|
||||
reasonPlaceholder: 'Enter refund reason (optional)',
|
||||
forceRefund: 'Force refund (balance may become negative)',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm Refund',
|
||||
processing: 'Processing...',
|
||||
}
|
||||
: {
|
||||
title: '确认退款',
|
||||
orderId: '订单号',
|
||||
amount: '退款金额',
|
||||
reason: '退款原因',
|
||||
reasonPlaceholder: '请输入退款原因(可选)',
|
||||
forceRefund: '强制退款(余额可能扣为负数)',
|
||||
cancel: '取消',
|
||||
confirm: '确认退款',
|
||||
processing: '处理中...',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
@@ -51,17 +80,17 @@ export default function RefundDialog({
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>确认退款</h3>
|
||||
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.title}</h3>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>订单号</div>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.orderId}</div>
|
||||
<div className="text-sm font-mono">{orderId}</div>
|
||||
</div>
|
||||
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>退款金额</div>
|
||||
<div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.amount}</div>
|
||||
<div className="text-lg font-bold text-red-600">{currency}{amount.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
{warning && (
|
||||
@@ -77,13 +106,13 @@ export default function RefundDialog({
|
||||
|
||||
<div>
|
||||
<label className={['mb-1 block text-sm font-medium', dark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
|
||||
退款原因
|
||||
{text.reason}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="请输入退款原因(可选)"
|
||||
placeholder={text.reasonPlaceholder}
|
||||
className={[
|
||||
'w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none',
|
||||
dark ? 'border-slate-600 bg-slate-800 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||
@@ -99,7 +128,7 @@ export default function RefundDialog({
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
className={['rounded', dark ? 'border-slate-600' : 'border-gray-300'].join(' ')}
|
||||
/>
|
||||
<span className="text-red-600">强制退款(余额可能扣为负数)</span>
|
||||
<span className="text-red-600">{text.forceRefund}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
@@ -114,14 +143,14 @@ export default function RefundDialog({
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
].join(' ')}
|
||||
>
|
||||
取消
|
||||
{text.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || (requireForce && !force)}
|
||||
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
{loading ? '处理中...' : '确认退款'}
|
||||
{loading ? text.processing : text.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user