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:
erio
2026-03-09 18:33:57 +08:00
parent 5cebe85079
commit 2492031e13
35 changed files with 1997 additions and 579 deletions

View File

@@ -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"

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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>