mirror of
https://github.com/touwaeriol/sub2apipay.git
synced 2026-08-29 02:26:50 +08:00
merge: PR #20 用户退款申请 + 部分退款 + 自动退款
合并 fix/refund-workflow-and-deploy-updates 分支,解决 6 个冲突: - 新增 REFUND_REQUESTED / PARTIALLY_REFUNDED 状态 - 用户退款申请接口 POST /api/orders/[id]/refund-request - 部分退款支持(amount 参数) - 自动退款开关 AUTO_REFUND_ENABLED - 保留已有的 prepareDeduction/executeDeduction/rollbackDeduction 拆分 - 保留订阅退款、deductBalance 开关、实例退款开关 - 修正幂等键、审计日志字段
This commit is contained in:
@@ -14,9 +14,13 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://sub2apipay:${DB_PASSWORD:-password}@db:5432/sub2apipay
|
||||
- HOSTNAME=0.0.0.0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default
|
||||
- sub2api-network
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
@@ -36,3 +40,8 @@ services:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
sub2api-network:
|
||||
external: true
|
||||
name: deploy_default
|
||||
|
||||
@@ -6,9 +6,13 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://sub2apipay:${DB_PASSWORD:-password}@db:5432/sub2apipay
|
||||
- HOSTNAME=0.0.0.0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- default
|
||||
- sub2api-network
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
@@ -22,7 +26,14 @@ services:
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U sub2apipay']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
sub2api-network:
|
||||
external: true
|
||||
name: deploy_default
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add refund approval status and request metadata
|
||||
ALTER TYPE "OrderStatus" ADD VALUE IF NOT EXISTS 'REFUND_REQUESTED';
|
||||
|
||||
ALTER TABLE "orders"
|
||||
ADD COLUMN IF NOT EXISTS "refund_requested_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "refund_request_reason" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "refund_requested_by" INTEGER;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add partially refunded status
|
||||
ALTER TYPE "OrderStatus" ADD VALUE IF NOT EXISTS 'PARTIALLY_REFUNDED';
|
||||
@@ -28,6 +28,9 @@ model Order {
|
||||
refundReason String? @map("refund_reason")
|
||||
refundAt DateTime? @map("refund_at")
|
||||
forceRefund Boolean @default(false) @map("force_refund")
|
||||
refundRequestedAt DateTime? @map("refund_requested_at")
|
||||
refundRequestReason String? @map("refund_request_reason")
|
||||
refundRequestedBy Int? @map("refund_requested_by")
|
||||
|
||||
expiresAt DateTime @map("expires_at")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
@@ -68,7 +71,9 @@ enum OrderStatus {
|
||||
EXPIRED
|
||||
CANCELLED
|
||||
FAILED
|
||||
REFUND_REQUESTED
|
||||
REFUNDING
|
||||
PARTIALLY_REFUNDED
|
||||
REFUNDED
|
||||
REFUND_FAILED
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ describe('processRefund', () => {
|
||||
expect(mockOrderUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { in: [ORDER_STATUS.COMPLETED, ORDER_STATUS.REFUND_FAILED] },
|
||||
status: { in: [ORDER_STATUS.COMPLETED, ORDER_STATUS.REFUND_REQUESTED, ORDER_STATUS.REFUND_FAILED] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -570,7 +570,8 @@ describe('processRefund', () => {
|
||||
expect(detail.subscriptionDaysDeducted).toBe(0);
|
||||
expect(detail.reason).toBe('测试退款');
|
||||
expect(detail.rechargeAmount).toBe(100);
|
||||
expect(detail.refundAmount).toBe(103); // payAmount
|
||||
expect(detail.refundAmount).toBe(100); // rechargeAmount (default, no input.amount)
|
||||
expect(detail.gatewayRefundAmount).toBe(103); // payAmount
|
||||
});
|
||||
|
||||
it('退款成功时审计日志包含订阅扣减天数', async () => {
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('Sub2API Client', () => {
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(fetchCall[1].body as string);
|
||||
expect(body.operation).toBe('subtract');
|
||||
expect(body.amount).toBe(50);
|
||||
expect(body.balance).toBe(50);
|
||||
});
|
||||
|
||||
// ── addBalance ──
|
||||
@@ -252,7 +252,7 @@ describe('Sub2API Client', () => {
|
||||
expect(fetchCall[0]).toContain('/users/1/balance');
|
||||
const body = JSON.parse(fetchCall[1].body as string);
|
||||
expect(body.operation).toBe('add');
|
||||
expect(body.amount).toBe(100);
|
||||
expect(body.balance).toBe(100);
|
||||
const headers = fetchCall[1].headers as Record<string, string>;
|
||||
expect(headers['Idempotency-Key']).toBe('idem-add-1');
|
||||
});
|
||||
|
||||
@@ -28,6 +28,9 @@ interface AdminOrder {
|
||||
orderType?: string;
|
||||
subscriptionDays?: number | null;
|
||||
subscriptionGroupId?: number | null;
|
||||
refundRequestedAt?: string | null;
|
||||
refundRequestReason?: string | null;
|
||||
refundAmount?: number | null;
|
||||
}
|
||||
|
||||
interface AdminOrderDetail extends AdminOrder {
|
||||
@@ -37,6 +40,7 @@ interface AdminOrderDetail extends AdminOrder {
|
||||
refundReason: string | null;
|
||||
refundAt: string | null;
|
||||
forceRefund: boolean;
|
||||
refundRequestedBy?: number | null;
|
||||
failedAt: string | null;
|
||||
updatedAt: string;
|
||||
clientIp: string | null;
|
||||
@@ -76,6 +80,7 @@ function AdminContent() {
|
||||
cancelFailed: 'Cancel failed',
|
||||
cancelRequestFailed: 'Cancel request failed',
|
||||
refundFailed: 'Refund failed',
|
||||
refundRequestFailed: 'Refund request failed',
|
||||
loadDetailFailed: 'Failed to load order details',
|
||||
title: 'Order Management',
|
||||
subtitle: 'View and manage all recharge orders',
|
||||
@@ -92,11 +97,13 @@ function AdminContent() {
|
||||
PAID: 'Paid',
|
||||
RECHARGING: 'Recharging',
|
||||
COMPLETED: 'Completed',
|
||||
REFUND_REQUESTED: 'Requested',
|
||||
REFUNDING: 'Refunding',
|
||||
EXPIRED: 'Expired',
|
||||
CANCELLED: 'Cancelled',
|
||||
FAILED: 'Recharge failed',
|
||||
REFUNDED: 'Refunded',
|
||||
REFUNDING: 'Refunding',
|
||||
PARTIALLY_REFUNDED: 'Partially refunded',
|
||||
REFUND_FAILED: 'Refund Failed',
|
||||
},
|
||||
}
|
||||
@@ -113,6 +120,7 @@ function AdminContent() {
|
||||
cancelFailed: '取消失败',
|
||||
cancelRequestFailed: '取消请求失败',
|
||||
refundFailed: '退款失败',
|
||||
refundRequestFailed: '退款请求失败',
|
||||
loadDetailFailed: '加载订单详情失败',
|
||||
title: '订单管理',
|
||||
subtitle: '查看和管理所有充值订单',
|
||||
@@ -129,11 +137,13 @@ function AdminContent() {
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
REFUND_REQUESTED: '申请中',
|
||||
REFUNDING: '退款中',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '充值失败',
|
||||
REFUNDED: '已退款',
|
||||
REFUNDING: '退款中',
|
||||
PARTIALLY_REFUNDED: '已部分退款',
|
||||
REFUND_FAILED: '退款失败',
|
||||
},
|
||||
};
|
||||
@@ -147,7 +157,6 @@ function AdminContent() {
|
||||
const [orderTypeFilter, setOrderTypeFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [detailOrder, setDetailOrder] = useState<AdminOrderDetail | null>(null);
|
||||
const [refundOrder, setRefundOrder] = useState<AdminOrder | null>(null);
|
||||
const [refundUserBalance, setRefundUserBalance] = useState<number | undefined>(undefined);
|
||||
@@ -201,9 +210,7 @@ function AdminContent() {
|
||||
const handleRetry = async (orderId: string) => {
|
||||
if (!confirm(text.retryConfirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
fetchOrders();
|
||||
} else {
|
||||
@@ -218,9 +225,7 @@ function AdminContent() {
|
||||
const handleCancel = async (orderId: string) => {
|
||||
if (!confirm(text.cancelConfirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
fetchOrders();
|
||||
} else {
|
||||
@@ -234,7 +239,11 @@ function AdminContent() {
|
||||
|
||||
const handleRefund = async (orderId: string) => {
|
||||
const order = orders.find((o) => o.id === orderId);
|
||||
if (!order || (order.status !== 'COMPLETED' && order.status !== 'REFUND_FAILED')) return;
|
||||
if (
|
||||
!order ||
|
||||
(order.status !== 'COMPLETED' && order.status !== 'REFUND_REQUESTED' && order.status !== 'REFUND_FAILED')
|
||||
)
|
||||
return;
|
||||
setRefundOrder(order);
|
||||
setRefundWarning(undefined);
|
||||
setRefundRequireForce(false);
|
||||
@@ -271,7 +280,7 @@ function AdminContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmRefund = async (reason: string, force: boolean, deductBalance: boolean) => {
|
||||
const handleConfirmRefund = async (reason: string, force: boolean, deductBalance: boolean, amount?: number) => {
|
||||
if (!refundOrder) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/refund?token=${token}`, {
|
||||
@@ -279,6 +288,7 @@ function AdminContent() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
order_id: refundOrder.id,
|
||||
amount,
|
||||
reason,
|
||||
force,
|
||||
deduct_balance: deductBalance,
|
||||
@@ -306,6 +316,10 @@ function AdminContent() {
|
||||
setRefundWarning(undefined);
|
||||
setRefundRequireForce(false);
|
||||
await fetchOrders();
|
||||
if (detailOrder?.id === refundOrder.id) {
|
||||
const detailRes = await fetch(`/api/admin/orders/${refundOrder.id}?token=${token}`);
|
||||
if (detailRes.ok) setDetailOrder(await detailRes.json());
|
||||
}
|
||||
} catch {
|
||||
setError(text.refundFailed);
|
||||
}
|
||||
@@ -329,10 +343,12 @@ function AdminContent() {
|
||||
'PAID',
|
||||
'RECHARGING',
|
||||
'COMPLETED',
|
||||
'REFUND_REQUESTED',
|
||||
'REFUNDING',
|
||||
'PARTIALLY_REFUNDED',
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
'FAILED',
|
||||
'REFUNDING',
|
||||
'REFUNDED',
|
||||
'REFUND_FAILED',
|
||||
];
|
||||
@@ -356,11 +372,9 @@ function AdminContent() {
|
||||
subtitle={text.subtitle}
|
||||
locale={locale}
|
||||
actions={
|
||||
<>
|
||||
<button type="button" onClick={fetchOrders} className={btnBase}>
|
||||
{text.refresh}
|
||||
</button>
|
||||
</>
|
||||
<button type="button" onClick={fetchOrders} className={btnBase}>
|
||||
{text.refresh}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
@@ -374,7 +388,6 @@ function AdminContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
@@ -421,7 +434,6 @@ function AdminContent() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border',
|
||||
@@ -465,6 +477,7 @@ function AdminContent() {
|
||||
<RefundDialog
|
||||
orderId={refundOrder.id}
|
||||
amount={refundOrder.payAmount ?? refundOrder.amount}
|
||||
requestedAmount={refundOrder.refundAmount ?? refundOrder.payAmount ?? refundOrder.amount}
|
||||
orderType={refundOrder.orderType}
|
||||
userBalance={refundUserBalance}
|
||||
subscriptionDays={refundOrder.subscriptionDays ?? undefined}
|
||||
|
||||
@@ -69,6 +69,8 @@ function getTexts(locale: Locale) {
|
||||
allChannels: 'All Channels',
|
||||
sub2apiAdminApiKey: 'Sub2API Admin API Key',
|
||||
sub2apiAdminApiKeyHint: 'Leave empty to use environment variable',
|
||||
autoRefund: 'Auto Refund',
|
||||
autoRefundHint: 'When enabled, user refund requests are executed immediately without admin approval',
|
||||
}
|
||||
: {
|
||||
missingToken: '缺少管理员凭证',
|
||||
@@ -130,6 +132,8 @@ function getTexts(locale: Locale) {
|
||||
allChannels: '全部渠道',
|
||||
sub2apiAdminApiKey: 'Sub2API Admin API Key',
|
||||
sub2apiAdminApiKeyHint: '留空则使用环境变量',
|
||||
autoRefund: '自动退款',
|
||||
autoRefundHint: '开启后,用户提交退款申请会立即执行退款,无需管理员审核',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,6 +259,7 @@ function PaymentConfigContent() {
|
||||
const [rcSaving, setRcSaving] = useState(false);
|
||||
const [rcLoadBalanceStrategy, setRcLoadBalanceStrategy] = useState('round-robin');
|
||||
const [rcSub2apiKey, setRcSub2apiKey] = useState('');
|
||||
const [rcAutoRefundEnabled, setRcAutoRefundEnabled] = useState(false);
|
||||
|
||||
// Override env
|
||||
const [rcOverrideEnv, setRcOverrideEnv] = useState(false);
|
||||
@@ -322,6 +327,7 @@ function PaymentConfigContent() {
|
||||
if (c.key === 'ORDER_TIMEOUT_MINUTES') setRcOrderTimeout(c.value);
|
||||
if (c.key === 'LOAD_BALANCE_STRATEGY') setRcLoadBalanceStrategy(c.value || 'round-robin');
|
||||
if (c.key === 'SUB2API_ADMIN_API_KEY') setRcSub2apiKey(/\*{4,}/.test(c.value) ? '' : c.value);
|
||||
if (c.key === 'AUTO_REFUND_ENABLED') setRcAutoRefundEnabled(c.value === 'true');
|
||||
}
|
||||
setRcOverrideEnv(hasOverride);
|
||||
setRcOverrideSaved(hasOverride);
|
||||
@@ -570,6 +576,12 @@ function PaymentConfigContent() {
|
||||
group: 'payment',
|
||||
label: '余额充值禁用',
|
||||
},
|
||||
{
|
||||
key: 'AUTO_REFUND_ENABLED',
|
||||
value: rcAutoRefundEnabled ? 'true' : 'false',
|
||||
group: 'payment',
|
||||
label: '自动退款开关',
|
||||
},
|
||||
{
|
||||
key: 'CANCEL_RATE_LIMIT_ENABLED',
|
||||
value: rcCancelRateLimitEnabled ? 'true' : 'false',
|
||||
@@ -755,6 +767,19 @@ function PaymentConfigContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles row */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle value={rcBalanceEnabled} onChange={() => setRcBalanceEnabled(!rcBalanceEnabled)} />
|
||||
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>{t.enableBalanceRecharge}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle value={rcAutoRefundEnabled} onChange={() => setRcAutoRefundEnabled(!rcAutoRefundEnabled)} />
|
||||
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-slate-700'}`}>{t.autoRefund}</span>
|
||||
</div>
|
||||
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{t.autoRefundHint}</span>
|
||||
</div>
|
||||
|
||||
{/* Cancel rate limit */}
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
|
||||
@@ -94,6 +94,7 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'ENABLED_PROVIDERS',
|
||||
'SUB2API_ADMIN_API_KEY',
|
||||
'OVERRIDE_ENV_ENABLED',
|
||||
'AUTO_REFUND_ENABLED',
|
||||
]);
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
|
||||
@@ -19,7 +19,6 @@ export async function GET(request: NextRequest) {
|
||||
if (status && status in OrderStatus) where.status = status as OrderStatus;
|
||||
if (orderType && (orderType === 'balance' || orderType === 'subscription')) where.orderType = orderType;
|
||||
|
||||
// userId 校验:忽略无效值(NaN)
|
||||
if (userId) {
|
||||
const parsedUserId = Number(userId);
|
||||
if (Number.isFinite(parsedUserId)) {
|
||||
@@ -27,7 +26,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 日期校验:忽略无效日期
|
||||
if (dateFrom || dateTo) {
|
||||
const createdAt: Prisma.DateTimeFilter = {};
|
||||
let hasValidDate = false;
|
||||
@@ -80,6 +78,9 @@ export async function GET(request: NextRequest) {
|
||||
subscriptionDays: true,
|
||||
refundAmount: true,
|
||||
refundAt: true,
|
||||
refundRequestedAt: true,
|
||||
refundRequestReason: true,
|
||||
refundRequestedBy: true,
|
||||
},
|
||||
}),
|
||||
prisma.order.count({ where }),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveLocale } from '@/lib/locale';
|
||||
|
||||
const refundSchema = z.object({
|
||||
order_id: z.string().min(1),
|
||||
amount: z.number().positive().optional(),
|
||||
reason: z.string().optional(),
|
||||
force: z.boolean().optional().default(false),
|
||||
deduct_balance: z.boolean().optional().default(true),
|
||||
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const result = await processRefund({
|
||||
orderId: parsed.data.order_id,
|
||||
amount: parsed.data.amount,
|
||||
reason: parsed.data.reason,
|
||||
force: parsed.data.force,
|
||||
deductBalance: parsed.data.deduct_balance,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
import { requestRefund } from '@/lib/order/service';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
import { handleApiError } from '@/lib/utils/api';
|
||||
|
||||
const refundRequestSchema = z.object({
|
||||
amount: z.number().positive(),
|
||||
reason: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
|
||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getCurrentUserByToken(token);
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = refundRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: locale === 'en' ? 'Invalid parameters' : '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const result = await requestRefund({
|
||||
orderId: id,
|
||||
userId: user.id,
|
||||
amount: parsed.data.amount,
|
||||
reason: parsed.data.reason,
|
||||
locale,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return handleApiError(error, locale === 'en' ? 'Refund request failed' : '退款申请失败', request);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ export async function GET(request: NextRequest) {
|
||||
const rawPageSize = Number(searchParams.get('page_size') || '20');
|
||||
const pageSize = VALID_PAGE_SIZES.includes(rawPageSize) ? rawPageSize : 20;
|
||||
|
||||
// 单独处理认证,区分认证失败和其他错误
|
||||
let user;
|
||||
try {
|
||||
user = await getCurrentUserByToken(token);
|
||||
@@ -44,6 +43,9 @@ export async function GET(request: NextRequest) {
|
||||
completedAt: true,
|
||||
orderType: true,
|
||||
providerInstanceId: true,
|
||||
refundRequestedAt: true,
|
||||
refundRequestReason: true,
|
||||
refundAmount: true,
|
||||
},
|
||||
}),
|
||||
prisma.order.count({ where }),
|
||||
@@ -86,6 +88,9 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: item.createdAt,
|
||||
orderType: item.orderType,
|
||||
canRefundRequest: item.orderType === 'balance' && item.status === 'COMPLETED' && instanceRefundEnabled,
|
||||
refundRequestedAt: item.refundRequestedAt,
|
||||
refundRequestReason: item.refundRequestReason,
|
||||
refundAmount: item.refundAmount ? Number(item.refundAmount) : null,
|
||||
paymentSuccess: derived.paymentSuccess,
|
||||
rechargeSuccess: derived.rechargeSuccess,
|
||||
rechargeStatus: derived.rechargeStatus,
|
||||
|
||||
+39
-25
@@ -30,16 +30,8 @@ function OrdersContent() {
|
||||
|
||||
const text = {
|
||||
missingAuth: pickLocaleText(locale, '缺少认证信息', 'Missing authentication information'),
|
||||
visitOrders: pickLocaleText(
|
||||
locale,
|
||||
'请从 Sub2API 平台正确访问订单页面',
|
||||
'Please open the orders page from Sub2API',
|
||||
),
|
||||
sessionExpired: pickLocaleText(
|
||||
locale,
|
||||
'登录态已失效,请从 Sub2API 重新进入支付页。',
|
||||
'Session expired. Please re-enter from Sub2API.',
|
||||
),
|
||||
visitOrders: pickLocaleText(locale, '请从 Sub2API 平台正确访问订单页面', 'Please open the orders page from Sub2API'),
|
||||
sessionExpired: pickLocaleText(locale, '登录态已失效,请从 Sub2API 重新进入支付页。', 'Session expired. Please re-enter from Sub2API.'),
|
||||
loadFailed: pickLocaleText(locale, '订单加载失败,请稍后重试。', 'Failed to load orders. Please try again later.'),
|
||||
networkError: pickLocaleText(locale, '网络错误,请稍后重试。', 'Network error. Please try again later.'),
|
||||
switchingMobileTab: pickLocaleText(locale, '正在切换到移动端订单 Tab...', 'Switching to mobile orders tab...'),
|
||||
@@ -48,11 +40,8 @@ function OrdersContent() {
|
||||
backToPay: pickLocaleText(locale, '返回充值', 'Back to Top Up'),
|
||||
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
|
||||
userPrefix: pickLocaleText(locale, '用户', 'User'),
|
||||
authError: pickLocaleText(
|
||||
locale,
|
||||
'缺少认证信息,请从 Sub2API 平台正确访问订单页面',
|
||||
'Missing authentication information. Please open the orders page from Sub2API.',
|
||||
),
|
||||
authError: pickLocaleText(locale, '缺少认证信息,请从 Sub2API 平台正确访问订单页面', 'Missing authentication information. Please open the orders page from Sub2API.'),
|
||||
refundRequestFailed: pickLocaleText(locale, '退款申请失败,请稍后重试。', 'Refund request failed. Please try again later.'),
|
||||
};
|
||||
|
||||
const [isIframeContext, setIsIframeContext] = useState(true);
|
||||
@@ -153,20 +142,33 @@ function OrdersContent() {
|
||||
loadOrders(1, newSize);
|
||||
};
|
||||
|
||||
const handleRefundRequest = async (orderId: string, amount: number, reason: string) => {
|
||||
const params = new URLSearchParams({ token });
|
||||
applyLocaleToSearchParams(params, locale);
|
||||
const res = await fetch(`/api/orders/${orderId}/refund-request?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount, reason }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || text.refundRequestFailed);
|
||||
}
|
||||
|
||||
await loadOrders(page, pageSize);
|
||||
};
|
||||
|
||||
const filteredOrders = activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
|
||||
|
||||
const btnClass = [
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ');
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
|
||||
>
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}>
|
||||
{text.switchingMobileTab}
|
||||
</div>
|
||||
);
|
||||
@@ -217,7 +219,21 @@ function OrdersContent() {
|
||||
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
|
||||
</div>
|
||||
|
||||
<OrderTable isDark={isDark} locale={locale} loading={loading} error={error} orders={filteredOrders} />
|
||||
<OrderTable
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
loading={loading}
|
||||
error={error}
|
||||
orders={filteredOrders}
|
||||
userBalance={userInfo?.balance ?? 0}
|
||||
onRefundRequest={async (orderId, amount, reason) => {
|
||||
try {
|
||||
await handleRefundRequest(orderId, amount, reason);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : text.refundRequestFailed);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
@@ -242,9 +258,7 @@ function OrdersPageFallback() {
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+265
-67
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import {
|
||||
formatStatus,
|
||||
@@ -13,9 +16,11 @@ interface OrderTableProps {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
orders: MyOrder[];
|
||||
userBalance: number;
|
||||
onRefundRequest: (orderId: string, amount: number, reason: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function OrderTable({ isDark, locale, loading, error, orders }: OrderTableProps) {
|
||||
export default function OrderTable({ isDark, locale, loading, error, orders, userBalance, onRefundRequest }: OrderTableProps) {
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
@@ -25,6 +30,22 @@ export default function OrderTable({ isDark, locale, loading, error, orders }: O
|
||||
payment: 'Payment Method',
|
||||
status: 'Status',
|
||||
createdAt: 'Created At',
|
||||
actions: 'Actions',
|
||||
refundRequest: 'Request Refund',
|
||||
requested: 'Requested',
|
||||
partialRefunded: 'Partially refunded',
|
||||
dialogTitle: 'Refund Request',
|
||||
refundAmount: 'Refund Amount',
|
||||
refundReason: 'Refund Reason',
|
||||
refundReasonPlaceholder: 'Enter refund reason (optional)',
|
||||
currentBalance: 'Current Balance',
|
||||
orderAmount: 'Order Amount',
|
||||
cancel: 'Cancel',
|
||||
submit: 'Submit Request',
|
||||
submitting: 'Submitting...',
|
||||
refundAmountInvalid: 'Refund amount must be greater than 0',
|
||||
refundAmountExceedOrder: 'Refund amount cannot exceed order amount',
|
||||
refundAmountExceedBalance: 'Refund amount cannot exceed current balance',
|
||||
}
|
||||
: {
|
||||
empty: '暂无符合条件的订单记录',
|
||||
@@ -33,85 +54,262 @@ export default function OrderTable({ isDark, locale, loading, error, orders }: O
|
||||
payment: '支付方式',
|
||||
status: '状态',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作',
|
||||
refundRequest: '申请退款',
|
||||
requested: '已申请',
|
||||
partialRefunded: '已部分退款',
|
||||
dialogTitle: '申请退款',
|
||||
refundAmount: '退款金额',
|
||||
refundReason: '退款原因',
|
||||
refundReasonPlaceholder: '请输入退款原因(可选)',
|
||||
currentBalance: '当前余额',
|
||||
orderAmount: '订单金额',
|
||||
cancel: '取消',
|
||||
submit: '提交申请',
|
||||
submitting: '提交中...',
|
||||
refundAmountInvalid: '退款金额必须大于 0',
|
||||
refundAmountExceedOrder: '退款金额不能超过订单金额',
|
||||
refundAmountExceedBalance: '退款金额不能超过当前余额',
|
||||
};
|
||||
|
||||
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||
const [refundOrder, setRefundOrder] = useState<MyOrder | null>(null);
|
||||
const [refundAmount, setRefundAmount] = useState('');
|
||||
const [refundReason, setRefundReason] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!refundOrder) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && submittingId !== refundOrder.id) {
|
||||
setRefundOrder(null);
|
||||
setRefundAmount('');
|
||||
setRefundReason('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [refundOrder, submittingId]);
|
||||
|
||||
const parsedRefundAmount = Number(refundAmount);
|
||||
const amountError = !refundOrder
|
||||
? ''
|
||||
: !Number.isFinite(parsedRefundAmount) || parsedRefundAmount <= 0
|
||||
? text.refundAmountInvalid
|
||||
: parsedRefundAmount > refundOrder.amount
|
||||
? text.refundAmountExceedOrder
|
||||
: parsedRefundAmount > userBalance
|
||||
? text.refundAmountExceedBalance
|
||||
: '';
|
||||
|
||||
const openRefundDialog = (order: MyOrder) => {
|
||||
setRefundOrder(order);
|
||||
setRefundAmount((order.refundAmount ?? order.amount).toFixed(2));
|
||||
setRefundReason(order.refundRequestReason ?? '');
|
||||
};
|
||||
|
||||
const closeRefundDialog = () => {
|
||||
if (refundOrder && submittingId === refundOrder.id) return;
|
||||
setRefundOrder(null);
|
||||
setRefundAmount('');
|
||||
setRefundReason('');
|
||||
};
|
||||
|
||||
const handleRefundRequest = async () => {
|
||||
if (!refundOrder || amountError) return;
|
||||
setSubmittingId(refundOrder.id);
|
||||
try {
|
||||
await onRefundRequest(refundOrder.id, parsedRefundAmount, refundReason);
|
||||
setRefundOrder(null);
|
||||
setRefundAmount('');
|
||||
setRefundReason('');
|
||||
} finally {
|
||||
setSubmittingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-3 sm:p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50/80',
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<>
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-3 sm:p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50/80',
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div
|
||||
className={[
|
||||
'h-6 w-6 animate-spin rounded-full border-2 border-t-transparent',
|
||||
isDark ? 'border-slate-400' : 'border-slate-500',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className={[
|
||||
'h-6 w-6 animate-spin rounded-full border-2 border-t-transparent',
|
||||
isDark ? 'border-slate-400' : 'border-slate-500',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border border-dashed px-4 py-10 text-center text-sm',
|
||||
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border border-dashed px-4 py-10 text-center text-sm',
|
||||
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
{text.empty}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={[
|
||||
'hidden rounded-xl px-4 py-2 text-xs font-medium md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr]',
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
'rounded-xl border border-dashed px-4 py-10 text-center text-sm',
|
||||
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>{text.orderId}</span>
|
||||
<span>{text.amount}</span>
|
||||
<span>{text.payment}</span>
|
||||
<span>{text.status}</span>
|
||||
<span>{text.createdAt}</span>
|
||||
{error}
|
||||
</div>
|
||||
<div className="space-y-2 md:space-y-0">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className={[
|
||||
'border-t px-4 py-3 first:border-t-0 md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr] md:items-center',
|
||||
isDark ? 'border-slate-700 text-slate-200' : 'border-slate-200 text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="font-medium">#{order.id.slice(0, 12)}</div>
|
||||
<div className="font-semibold">¥{order.amount.toFixed(2)}</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(
|
||||
' ',
|
||||
) : orders.length === 0 ? (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border border-dashed px-4 py-10 text-center text-sm',
|
||||
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
{text.empty}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={[
|
||||
'hidden rounded-xl px-4 py-2 text-xs font-medium md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr_0.8fr]',
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>{text.orderId}</span>
|
||||
<span>{text.amount}</span>
|
||||
<span>{text.payment}</span>
|
||||
<span>{text.status}</span>
|
||||
<span>{text.createdAt}</span>
|
||||
<span>{text.actions}</span>
|
||||
</div>
|
||||
<div className="space-y-2 md:space-y-0">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className={[
|
||||
'border-t px-4 py-3 first:border-t-0 md:grid md:grid-cols-[1.2fr_0.6fr_0.8fr_0.8fr_1fr_0.8fr] md:items-center',
|
||||
isDark ? 'border-slate-700 text-slate-200' : 'border-slate-200 text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="font-medium">#{order.id.slice(0, 12)}</div>
|
||||
<div className="font-semibold">¥{order.amount.toFixed(2)}</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, locale)}
|
||||
</span>
|
||||
{(order.status === 'PARTIALLY_REFUNDED' || order.status === 'REFUND_REQUESTED') && order.refundAmount != null && (
|
||||
<div className={['mt-1 text-xs', isDark ? 'text-fuchsia-300' : 'text-fuchsia-700'].join(' ')}>
|
||||
{order.status === 'PARTIALLY_REFUNDED' ? text.partialRefunded : text.requested}: ¥{order.refundAmount.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt, locale)}</div>
|
||||
<div>
|
||||
{order.canRefundRequest ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={submittingId === order.id}
|
||||
onClick={() => openRefundDialog(order)}
|
||||
className={[
|
||||
'rounded px-2 py-1 text-xs',
|
||||
isDark
|
||||
? 'bg-red-500/20 text-red-300 hover:bg-red-500/30 disabled:opacity-50'
|
||||
: 'bg-red-100 text-red-700 hover:bg-red-200 disabled:opacity-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{submittingId === order.id ? '...' : text.refundRequest}
|
||||
</button>
|
||||
) : order.status === 'REFUND_REQUESTED' ? (
|
||||
<span className={isDark ? 'text-violet-300 text-xs' : 'text-violet-700 text-xs'}>{text.requested}</span>
|
||||
) : (
|
||||
<span className={isDark ? 'text-slate-500 text-xs' : 'text-slate-400 text-xs'}>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||
{formatCreatedAt(order.createdAt, locale)}
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{refundOrder && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className={['w-full max-w-md rounded-xl p-6 shadow-xl', isDark ? 'bg-slate-900' : 'bg-white'].join(' ')}>
|
||||
<h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.dialogTitle}</h3>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className={['grid grid-cols-2 gap-3 text-sm', isDark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
|
||||
<div className={['rounded-lg p-3', isDark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{text.orderAmount}</div>
|
||||
<div className="mt-1 font-semibold">¥{refundOrder.amount.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className={['rounded-lg p-3', isDark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{text.currentBalance}</div>
|
||||
<div className="mt-1 font-semibold">¥{userBalance.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className={['mb-1 block text-sm font-medium', isDark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
|
||||
{text.refundAmount}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
max={Math.min(refundOrder.amount, userBalance).toFixed(2)}
|
||||
step="0.01"
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
className={[
|
||||
'w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none',
|
||||
isDark ? 'border-slate-600 bg-slate-800 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||
].join(' ')}
|
||||
/>
|
||||
{amountError && <div className={['mt-1 text-xs', isDark ? 'text-red-400' : 'text-red-600'].join(' ')}>{amountError}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={['mb-1 block text-sm font-medium', isDark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
|
||||
{text.refundReason}
|
||||
</label>
|
||||
<textarea
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
placeholder={text.refundReasonPlaceholder}
|
||||
rows={3}
|
||||
className={[
|
||||
'w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none',
|
||||
isDark ? 'border-slate-600 bg-slate-800 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeRefundDialog}
|
||||
disabled={submittingId === refundOrder.id}
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm',
|
||||
isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-800' : 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{text.cancel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefundRequest}
|
||||
disabled={submittingId === refundOrder.id || !!amountError}
|
||||
className={[
|
||||
'flex-1 rounded-lg py-2 text-sm font-medium text-white disabled:cursor-not-allowed',
|
||||
isDark ? 'bg-red-600/90 hover:bg-red-700 disabled:bg-slate-700 disabled:text-slate-500' : 'bg-red-600 hover:bg-red-700 disabled:bg-gray-300 disabled:text-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{submittingId === refundOrder.id ? text.submitting : text.submit}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { getPaymentDisplayInfo, formatCreatedAt } from '@/lib/pay-utils';
|
||||
import { getPaymentDisplayInfo, formatCreatedAt, formatStatus } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface AuditLog {
|
||||
@@ -27,6 +27,9 @@ interface OrderDetailProps {
|
||||
refundReason: string | null;
|
||||
refundAt: string | null;
|
||||
forceRefund: boolean;
|
||||
refundRequestedAt?: string | null;
|
||||
refundRequestReason?: string | null;
|
||||
refundRequestedBy?: number | null;
|
||||
expiresAt: string;
|
||||
paidAt: string | null;
|
||||
completedAt: string | null;
|
||||
@@ -85,6 +88,9 @@ export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: Ord
|
||||
completedAt: 'Completed At',
|
||||
failedAt: 'Failed At',
|
||||
failedReason: 'Failure Reason',
|
||||
refundRequestedAt: 'Refund Requested At',
|
||||
refundRequestReason: 'Refund Request Reason',
|
||||
refundRequestedBy: 'Refund Requested By',
|
||||
refundAmount: 'Refund Amount',
|
||||
refundReason: 'Refund Reason',
|
||||
refundAt: 'Refunded At',
|
||||
@@ -124,6 +130,9 @@ export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: Ord
|
||||
completedAt: '完成时间',
|
||||
failedAt: '失败时间',
|
||||
failedReason: '失败原因',
|
||||
refundRequestedAt: '申请退款时间',
|
||||
refundRequestReason: '申请退款原因',
|
||||
refundRequestedBy: '申请退款用户',
|
||||
refundAmount: '退款金额',
|
||||
refundReason: '退款原因',
|
||||
refundAt: '退款时间',
|
||||
@@ -150,7 +159,7 @@ export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: Ord
|
||||
{ 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.status, value: formatStatus(order.status, locale) },
|
||||
{
|
||||
label: text.orderType,
|
||||
value:
|
||||
@@ -180,18 +189,23 @@ export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: Ord
|
||||
{ label: text.failedReason, value: order.failedReason || '-' },
|
||||
];
|
||||
|
||||
if (order.refundRequestedAt || order.refundRequestReason || order.refundRequestedBy != null) {
|
||||
fields.push(
|
||||
{ label: text.refundRequestedAt, value: order.refundRequestedAt ? formatCreatedAt(order.refundRequestedAt, locale) : '-' },
|
||||
{ label: text.refundRequestReason, value: order.refundRequestReason || '-' },
|
||||
{ label: text.refundRequestedBy, value: order.refundRequestedBy != null ? String(order.refundRequestedBy) : '-' },
|
||||
);
|
||||
}
|
||||
|
||||
if (order.orderType === 'subscription') {
|
||||
fields.push(
|
||||
{ label: text.planId, value: order.planId || '-' },
|
||||
{
|
||||
label: text.subscriptionGroupId,
|
||||
value: order.subscriptionGroupId != null ? String(order.subscriptionGroupId) : '-',
|
||||
},
|
||||
{ label: text.subscriptionGroupId, value: order.subscriptionGroupId != null ? String(order.subscriptionGroupId) : '-' },
|
||||
{ label: text.subscriptionDays, value: order.subscriptionDays != null ? String(order.subscriptionDays) : '-' },
|
||||
);
|
||||
}
|
||||
|
||||
if (order.refundAmount) {
|
||||
if (order.refundAmount != null) {
|
||||
fields.push(
|
||||
{ label: text.refundAmount, value: `${currency}${order.refundAmount.toFixed(2)}` },
|
||||
{ label: text.refundReason, value: order.refundReason || '-' },
|
||||
@@ -202,15 +216,10 @@ export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: Ord
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className={`max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl p-6 shadow-xl ${dark ? 'bg-slate-800 text-slate-100' : 'bg-white'}`}
|
||||
>
|
||||
<div className={`max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl p-6 shadow-xl ${dark ? 'bg-slate-800 text-slate-100' : 'bg-white'}`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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'}
|
||||
>
|
||||
<button onClick={onClose} className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -224,43 +233,24 @@ export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: Ord
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Audit Logs */}
|
||||
<div className="mt-6">
|
||||
<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
|
||||
key={log.id}
|
||||
className={`rounded-lg border p-3 ${dark ? 'border-slate-600 bg-slate-700/60' : 'border-gray-100 bg-gray-50'}`}
|
||||
>
|
||||
<div key={log.id} className={`rounded-lg border p-3 ${dark ? 'border-slate-600 bg-slate-700/60' : 'border-gray-100 bg-gray-50'}`}>
|
||||
<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'}`}>
|
||||
{formatCreatedAt(log.createdAt, locale)}
|
||||
</span>
|
||||
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{formatCreatedAt(log.createdAt, locale)}</span>
|
||||
</div>
|
||||
{log.detail && (
|
||||
<div className={`mt-1 break-all text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{log.detail}
|
||||
</div>
|
||||
)}
|
||||
{log.operator && (
|
||||
<div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{text.operator}: {log.operator}
|
||||
</div>
|
||||
)}
|
||||
{log.detail && <div className={`mt-1 break-all text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>{log.detail}</div>}
|
||||
{log.operator && <div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{text.operator}: {log.operator}</div>}
|
||||
</div>
|
||||
))}
|
||||
{order.auditLogs.length === 0 && (
|
||||
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{text.emptyLogs}</div>
|
||||
)}
|
||||
{order.auditLogs.length === 0 && <div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{text.emptyLogs}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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'}`}
|
||||
>
|
||||
<button 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>
|
||||
|
||||
+110
-130
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentDisplayInfo, formatStatus, formatCreatedAt } from '@/lib/pay-utils';
|
||||
import { getPaymentDisplayInfo, formatStatus, formatCreatedAt, getStatusBadgeClass } from '@/lib/pay-utils';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
|
||||
interface Order {
|
||||
@@ -20,13 +20,16 @@ interface Order {
|
||||
srcHost: string | null;
|
||||
orderType?: string;
|
||||
rechargeRetryable?: boolean;
|
||||
refundRequestedAt?: string | null;
|
||||
refundRequestReason?: string | null;
|
||||
refundAmount?: number | null;
|
||||
}
|
||||
|
||||
interface OrderTableProps {
|
||||
orders: Order[];
|
||||
onRetry: (orderId: string) => void;
|
||||
onCancel: (orderId: string) => void;
|
||||
onRefund?: (orderId: string) => void;
|
||||
onRefund: (orderId: string) => void;
|
||||
onViewDetail: (orderId: string) => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
@@ -60,6 +63,7 @@ export default function OrderTable({
|
||||
cancel: 'Cancel',
|
||||
refund: 'Refund',
|
||||
retryRefund: 'Retry Refund',
|
||||
approveRefund: 'Approve Refund',
|
||||
empty: 'No orders',
|
||||
}
|
||||
: {
|
||||
@@ -78,6 +82,7 @@ export default function OrderTable({
|
||||
cancel: '取消',
|
||||
refund: '退款',
|
||||
retryRefund: '重试退款',
|
||||
approveRefund: '批准退款',
|
||||
empty: '暂无订单',
|
||||
};
|
||||
|
||||
@@ -103,135 +108,110 @@ export default function OrderTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60 bg-slate-900' : 'divide-gray-200 bg-white'}`}>
|
||||
{orders.map((order) => {
|
||||
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'}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => onViewDetail(order.id)}
|
||||
className={dark ? 'text-indigo-400 hover:underline' : 'text-blue-600 hover:underline'}
|
||||
>
|
||||
{order.id.slice(0, 12)}...
|
||||
</button>
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-200' : 'text-slate-900'}`}>
|
||||
{order.userName || `#${order.userId}`}
|
||||
</td>
|
||||
<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' : 'text-slate-900'}`}
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => onViewDetail(order.id)}
|
||||
className={dark ? 'text-indigo-400 hover:underline' : 'text-blue-600 hover:underline'}
|
||||
>
|
||||
{currency}
|
||||
{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${
|
||||
order.orderType === 'subscription'
|
||||
? dark
|
||||
? 'bg-purple-500/20 text-purple-300'
|
||||
: 'bg-purple-100 text-purple-800'
|
||||
: dark
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}
|
||||
>
|
||||
{order.orderType === 'subscription'
|
||||
? locale === 'en'
|
||||
? 'Subscription'
|
||||
: '订阅'
|
||||
: locale === 'en'
|
||||
? 'Recharge'
|
||||
: '充值'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${dark ? statusInfo.dark : statusInfo.light}`}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType, locale);
|
||||
return (
|
||||
<>
|
||||
{channel}
|
||||
{provider && (
|
||||
<span className={dark ? 'ml-1 text-xs text-slate-500' : 'ml-1 text-xs text-slate-400'}>
|
||||
{provider}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.srcHost || '-'}</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 && (
|
||||
<button
|
||||
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' && (
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
{(order.status === 'COMPLETED' || order.status === 'REFUND_FAILED') && onRefund && (
|
||||
<button
|
||||
onClick={() => onRefund(order.id)}
|
||||
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-violet-500/20 text-violet-300 hover:bg-violet-500/30' : 'bg-violet-100 text-violet-700 hover:bg-violet-200'}`}
|
||||
>
|
||||
{order.status === 'REFUND_FAILED' ? text.retryRefund : text.refund}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{order.id.slice(0, 12)}...
|
||||
</button>
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-200' : 'text-slate-900'}`}>
|
||||
{order.userName || `#${order.userId}`}
|
||||
</td>
|
||||
<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' : 'text-slate-900'}`}
|
||||
>
|
||||
{currency}
|
||||
{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${order.orderType === 'subscription' ? (dark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-800') : dark ? 'bg-blue-500/20 text-blue-300' : 'bg-blue-100 text-blue-800'}`}
|
||||
>
|
||||
{order.orderType === 'subscription'
|
||||
? locale === 'en'
|
||||
? 'Subscription'
|
||||
: '订阅'
|
||||
: locale === 'en'
|
||||
? 'Recharge'
|
||||
: '充值'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${getStatusBadgeClass(order.status, !!dark)}`}
|
||||
>
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType, locale);
|
||||
return (
|
||||
<>
|
||||
{channel}
|
||||
{provider && (
|
||||
<span className={dark ? 'ml-1 text-xs text-slate-500' : 'ml-1 text-xs text-slate-400'}>
|
||||
{provider}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.srcHost || '-'}</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 && (
|
||||
<button
|
||||
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' && (
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
{order.status === 'REFUND_REQUESTED' && order.refundAmount != null && (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${dark ? 'bg-violet-500/15 text-violet-300' : 'bg-violet-50 text-violet-700'}`}
|
||||
>
|
||||
{currency}
|
||||
{order.refundAmount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{order.status === 'REFUND_REQUESTED' && (
|
||||
<button
|
||||
onClick={() => onRefund(order.id)}
|
||||
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-violet-500/20 text-violet-300 hover:bg-violet-500/30' : 'bg-violet-100 text-violet-700 hover:bg-violet-200'}`}
|
||||
>
|
||||
{text.approveRefund}
|
||||
</button>
|
||||
)}
|
||||
{(order.status === 'COMPLETED' || order.status === 'REFUND_FAILED') && (
|
||||
<button
|
||||
onClick={() => onRefund(order.id)}
|
||||
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-violet-500/20 text-violet-300 hover:bg-violet-500/30' : 'bg-violet-100 text-violet-700 hover:bg-violet-200'}`}
|
||||
>
|
||||
{order.status === 'REFUND_FAILED' ? text.retryRefund : text.refund}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 && (
|
||||
|
||||
@@ -10,7 +10,8 @@ interface RefundDialogProps {
|
||||
userBalance?: number;
|
||||
subscriptionDays?: number;
|
||||
subscriptionRemainingDays?: number;
|
||||
onConfirm: (reason: string, force: boolean, deductBalance: boolean) => Promise<void>;
|
||||
requestedAmount?: number | null;
|
||||
onConfirm: (reason: string, force: boolean, deductBalance: boolean, amount?: number) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
warning?: string;
|
||||
requireForce?: boolean;
|
||||
@@ -25,6 +26,7 @@ export default function RefundDialog({
|
||||
userBalance,
|
||||
subscriptionDays,
|
||||
subscriptionRemainingDays,
|
||||
requestedAmount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
warning,
|
||||
@@ -33,6 +35,7 @@ export default function RefundDialog({
|
||||
locale = 'zh',
|
||||
}: RefundDialogProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [refundAmount, setRefundAmount] = useState((requestedAmount ?? amount).toFixed(2));
|
||||
const [force, setForce] = useState(false);
|
||||
const [deductBalance, setDeductBalance] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -44,7 +47,9 @@ export default function RefundDialog({
|
||||
? {
|
||||
title: 'Confirm Refund',
|
||||
orderId: 'Order ID',
|
||||
amount: 'Refund Amount',
|
||||
maxAmount: 'Order Amount',
|
||||
refundAmount: 'Refund Amount',
|
||||
refundAmountPlaceholder: 'Enter refund amount',
|
||||
reason: 'Refund Reason',
|
||||
reasonPlaceholder: 'Enter refund reason (optional)',
|
||||
forceRefund: 'Force refund (ignore balance check)',
|
||||
@@ -59,6 +64,8 @@ export default function RefundDialog({
|
||||
insufficientBalance: `Insufficient balance — will deduct to ${currency}0`,
|
||||
insufficientDays: 'Insufficient days — will deduct to 0 days',
|
||||
noDeduction: 'Will NOT deduct user balance / subscription',
|
||||
amountInvalid: 'Refund amount must be greater than 0',
|
||||
amountExceeded: 'Refund amount cannot exceed order amount',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm Refund',
|
||||
processing: 'Processing...',
|
||||
@@ -66,7 +73,9 @@ export default function RefundDialog({
|
||||
: {
|
||||
title: '确认退款',
|
||||
orderId: '订单号',
|
||||
amount: '退款金额',
|
||||
maxAmount: '订单金额',
|
||||
refundAmount: '退款金额',
|
||||
refundAmountPlaceholder: '请输入退款金额',
|
||||
reason: '退款原因',
|
||||
reasonPlaceholder: '请输入退款原因(可选)',
|
||||
forceRefund: '强制退款(忽略余额检查)',
|
||||
@@ -79,6 +88,8 @@ export default function RefundDialog({
|
||||
insufficientBalance: `余额不足,将扣至 ${currency}0`,
|
||||
insufficientDays: '剩余天数不足,将扣至 0 天',
|
||||
noDeduction: '将不扣除用户余额/订阅期限',
|
||||
amountInvalid: '退款金额必须大于 0',
|
||||
amountExceeded: '退款金额不能超过订单金额',
|
||||
cancel: '取消',
|
||||
confirm: '确认退款',
|
||||
processing: '处理中...',
|
||||
@@ -92,10 +103,19 @@ export default function RefundDialog({
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
const parsedRefundAmount = Number(refundAmount);
|
||||
const amountError =
|
||||
!Number.isFinite(parsedRefundAmount) || parsedRefundAmount <= 0
|
||||
? text.amountInvalid
|
||||
: parsedRefundAmount > amount
|
||||
? text.amountExceeded
|
||||
: '';
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (amountError) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onConfirm(reason, force, deductBalance);
|
||||
await onConfirm(reason, force, deductBalance, parsedRefundAmount);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -120,7 +140,7 @@ export default function RefundDialog({
|
||||
</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(' ')}>{text.amount}</div>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.maxAmount}</div>
|
||||
<div className={['text-lg font-bold', dark ? 'text-red-400' : 'text-red-600'].join(' ')}>
|
||||
{currency}
|
||||
{amount.toFixed(2)}
|
||||
@@ -218,6 +238,28 @@ export default function RefundDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={['mb-1 block text-sm font-medium', dark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
|
||||
{text.refundAmount}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
max={amount.toFixed(2)}
|
||||
step="0.01"
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
placeholder={text.refundAmountPlaceholder}
|
||||
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',
|
||||
].join(' ')}
|
||||
/>
|
||||
{amountError && (
|
||||
<div className={['mt-1 text-xs', dark ? 'text-red-400' : 'text-red-600'].join(' ')}>{amountError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={['mb-1 block text-sm font-medium', dark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
|
||||
{text.reason}
|
||||
@@ -261,7 +303,7 @@ export default function RefundDialog({
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || (requireForce && !force)}
|
||||
disabled={loading || !!amountError || (requireForce && !force)}
|
||||
className={[
|
||||
'flex-1 rounded-lg py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed',
|
||||
dark
|
||||
|
||||
@@ -7,7 +7,9 @@ export const ORDER_STATUS = {
|
||||
EXPIRED: 'EXPIRED',
|
||||
CANCELLED: 'CANCELLED',
|
||||
FAILED: 'FAILED',
|
||||
REFUND_REQUESTED: 'REFUND_REQUESTED',
|
||||
REFUNDING: 'REFUNDING',
|
||||
PARTIALLY_REFUNDED: 'PARTIALLY_REFUNDED',
|
||||
REFUNDED: 'REFUNDED',
|
||||
REFUND_FAILED: 'REFUND_FAILED',
|
||||
} as const;
|
||||
@@ -20,13 +22,16 @@ export const TERMINAL_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.FAILED,
|
||||
ORDER_STATUS.CANCELLED,
|
||||
ORDER_STATUS.EXPIRED,
|
||||
ORDER_STATUS.PARTIALLY_REFUNDED,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
|
||||
/** 退款相关状态 */
|
||||
export const REFUND_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.REFUND_REQUESTED,
|
||||
ORDER_STATUS.REFUNDING,
|
||||
ORDER_STATUS.PARTIALLY_REFUNDED,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
|
||||
+161
-18
@@ -1116,8 +1116,114 @@ export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Pro
|
||||
await executeFulfillment(orderId);
|
||||
}
|
||||
|
||||
export interface RefundRequestInput {
|
||||
orderId: string;
|
||||
userId: number;
|
||||
amount: number;
|
||||
reason?: string;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export async function requestRefund(input: RefundRequestInput): Promise<{ success: boolean }> {
|
||||
const locale = input.locale ?? 'zh';
|
||||
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
||||
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
||||
if (order.userId !== input.userId) {
|
||||
throw new OrderError('FORBIDDEN', message(locale, '无权申请该订单退款', 'Forbidden'), 403);
|
||||
}
|
||||
if (order.orderType !== 'balance') {
|
||||
throw new OrderError(
|
||||
'INVALID_ORDER_TYPE',
|
||||
message(locale, '仅余额充值订单支持退款申请', 'Only balance orders can request refund'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (order.status !== ORDER_STATUS.COMPLETED) {
|
||||
throw new OrderError(
|
||||
'INVALID_STATUS',
|
||||
message(locale, '仅已完成订单可申请退款', 'Only completed orders can request refund'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const refundAmount = input.amount;
|
||||
if (!Number.isFinite(refundAmount) || refundAmount <= 0) {
|
||||
throw new OrderError(
|
||||
'INVALID_REFUND_AMOUNT',
|
||||
message(locale, '退款金额必须大于 0', 'Refund amount must be greater than 0'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const maxRefundAmount = Number(order.amount);
|
||||
if (refundAmount > maxRefundAmount) {
|
||||
throw new OrderError(
|
||||
'REFUND_AMOUNT_EXCEEDED',
|
||||
message(locale, '退款金额不能超过充值金额', 'Refund amount cannot exceed recharge amount'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await getUser(order.userId);
|
||||
if (user.balance < refundAmount) {
|
||||
throw new OrderError(
|
||||
'BALANCE_NOT_ENOUGH',
|
||||
message(locale, '退款金额不能超过当前余额', 'Refund amount cannot exceed current balance'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const autoRefundEnabled = (await getSystemConfig('AUTO_REFUND_ENABLED')) === 'true';
|
||||
const normalizedReason = input.reason?.trim() || null;
|
||||
|
||||
const updated = await prisma.order.updateMany({
|
||||
where: { id: input.orderId, userId: input.userId, status: ORDER_STATUS.COMPLETED, orderType: 'balance' },
|
||||
data: {
|
||||
status: ORDER_STATUS.REFUND_REQUESTED,
|
||||
refundRequestedAt: new Date(),
|
||||
refundRequestReason: normalizedReason,
|
||||
refundRequestedBy: input.userId,
|
||||
refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
throw new OrderError(
|
||||
'CONFLICT',
|
||||
message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'),
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId: input.orderId,
|
||||
action: 'REFUND_REQUESTED',
|
||||
detail: JSON.stringify({
|
||||
amount: refundAmount,
|
||||
reason: normalizedReason,
|
||||
requestedBy: input.userId,
|
||||
autoRefundEnabled,
|
||||
}),
|
||||
operator: `user:${input.userId}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (autoRefundEnabled) {
|
||||
return processRefund({
|
||||
orderId: input.orderId,
|
||||
amount: refundAmount,
|
||||
reason: normalizedReason || undefined,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export interface RefundInput {
|
||||
orderId: string;
|
||||
amount?: number;
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
deductBalance?: boolean;
|
||||
@@ -1157,10 +1263,11 @@ async function prepareDeduction(
|
||||
deductBalance: boolean,
|
||||
force: boolean,
|
||||
locale: Locale,
|
||||
overrideAmount?: number,
|
||||
): Promise<DeductionPlan | RefundResult> {
|
||||
if (!deductBalance) return { type: 'none', balanceAmount: 0, subscriptionDays: 0, subscriptionId: null };
|
||||
|
||||
const rechargeAmount = Number(order.amount);
|
||||
const rechargeAmount = overrideAmount ?? Number(order.amount);
|
||||
|
||||
if (order.orderType === 'subscription') {
|
||||
if (!order.subscriptionGroupId || !order.subscriptionDays) {
|
||||
@@ -1311,25 +1418,56 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
const deductBalance = input.deductBalance ?? true;
|
||||
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
||||
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
|
||||
if (order.status !== ORDER_STATUS.COMPLETED && order.status !== ORDER_STATUS.REFUND_FAILED) {
|
||||
|
||||
const allowedStatuses = [ORDER_STATUS.COMPLETED, ORDER_STATUS.REFUND_REQUESTED, ORDER_STATUS.REFUND_FAILED];
|
||||
if (!allowedStatuses.includes(order.status as (typeof allowedStatuses)[number])) {
|
||||
throw new OrderError(
|
||||
'INVALID_STATUS',
|
||||
message(locale, '仅已完成或退款失败的订单允许退款', 'Only completed or refund-failed orders can be refunded'),
|
||||
message(
|
||||
locale,
|
||||
'仅已完成、已申请退款或退款失败的订单允许退款',
|
||||
'Only completed, refund-requested, or refund-failed orders can be refunded',
|
||||
),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const rechargeAmount = Number(order.amount);
|
||||
const refundAmount = Number(order.payAmount ?? order.amount);
|
||||
const maxGatewayRefund = Number(order.payAmount ?? order.amount);
|
||||
|
||||
// 部分退款支持:优先使用传入金额,否则全额
|
||||
const refundAmount = input.amount ?? rechargeAmount;
|
||||
if (!Number.isFinite(refundAmount) || refundAmount <= 0) {
|
||||
throw new OrderError(
|
||||
'INVALID_REFUND_AMOUNT',
|
||||
message(locale, '退款金额必须大于 0', 'Refund amount must be greater than 0'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (refundAmount > rechargeAmount) {
|
||||
throw new OrderError(
|
||||
'REFUND_AMOUNT_EXCEEDED',
|
||||
message(locale, '退款金额不能超过充值金额', 'Refund amount cannot exceed recharge amount'),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// 网关退款金额:部分退款时用 refundAmount,全额时用 payAmount
|
||||
const gatewayRefundAmount = input.amount ?? maxGatewayRefund;
|
||||
const refundReason =
|
||||
input.reason?.trim() || order.refundRequestReason?.trim() || `sub2apipay refund order:${order.id}`;
|
||||
|
||||
// 1. 准备扣减计划(可能提前返回 requireForce)
|
||||
const planOrResult = await prepareDeduction(order, deductBalance, input.force ?? false, locale);
|
||||
const planOrResult = await prepareDeduction(order, deductBalance, input.force ?? false, locale, input.amount);
|
||||
if (!isDeductionPlan(planOrResult)) return planOrResult;
|
||||
const plan = planOrResult;
|
||||
|
||||
// 2. CAS 乐观锁
|
||||
const lockResult = await prisma.order.updateMany({
|
||||
where: { id: input.orderId, status: { in: [ORDER_STATUS.COMPLETED, ORDER_STATUS.REFUND_FAILED] } },
|
||||
where: {
|
||||
id: input.orderId,
|
||||
status: { in: [ORDER_STATUS.COMPLETED, ORDER_STATUS.REFUND_REQUESTED, ORDER_STATUS.REFUND_FAILED] },
|
||||
},
|
||||
data: { status: ORDER_STATUS.REFUNDING },
|
||||
});
|
||||
if (lockResult.count === 0) {
|
||||
@@ -1363,21 +1501,23 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
await provider.refund({
|
||||
tradeNo: order.paymentTradeNo,
|
||||
orderId: order.id,
|
||||
amount: refundAmount,
|
||||
reason: input.reason,
|
||||
amount: gatewayRefundAmount,
|
||||
reason: refundReason,
|
||||
});
|
||||
} catch (gatewayError) {
|
||||
// 网关退款失败 — 回滚扣减
|
||||
const rollbackOk = await rollbackDeduction(input.orderId, order.userId, plan, gatewayError);
|
||||
|
||||
if (rollbackOk) {
|
||||
// 回滚成功 — 恢复 COMPLETED,返回失败结果(不 throw)
|
||||
await prisma.order.update({ where: { id: input.orderId }, data: { status: ORDER_STATUS.COMPLETED } });
|
||||
// 回滚成功 — 恢复原状态,返回失败结果(不 throw)
|
||||
const restoreStatus =
|
||||
order.status === ORDER_STATUS.REFUND_REQUESTED ? ORDER_STATUS.REFUND_REQUESTED : ORDER_STATUS.COMPLETED;
|
||||
await prisma.order.update({ where: { id: input.orderId }, data: { status: restoreStatus } });
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId: input.orderId,
|
||||
action: 'REFUND_GATEWAY_FAILED',
|
||||
detail: `Gateway refund failed, balance/subscription rolled back: ${errorMessage(gatewayError)}`,
|
||||
detail: `Gateway refund failed, deduction rolled back: ${errorMessage(gatewayError)}`,
|
||||
operator: 'admin',
|
||||
},
|
||||
});
|
||||
@@ -1385,8 +1525,8 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
success: false,
|
||||
warning: message(
|
||||
locale,
|
||||
`支付网关退款失败:${errorMessage(gatewayError)},已回滚扣减,订单恢复为已完成`,
|
||||
`Gateway refund failed: ${errorMessage(gatewayError)}, deduction rolled back, order restored`,
|
||||
`支付网关退款失败:${errorMessage(gatewayError)},已回滚扣减`,
|
||||
`Gateway refund failed: ${errorMessage(gatewayError)}, deduction rolled back`,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1417,13 +1557,15 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 标记退款成功
|
||||
// 5. 标记退款成功(部分/全额)
|
||||
const finalStatus = refundAmount < rechargeAmount ? ORDER_STATUS.PARTIALLY_REFUNDED : ORDER_STATUS.REFUNDED;
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
data: {
|
||||
status: ORDER_STATUS.REFUNDED,
|
||||
status: finalStatus,
|
||||
refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
|
||||
refundReason: input.reason || null,
|
||||
refundReason: refundReason,
|
||||
refundAt: new Date(),
|
||||
forceRefund: input.force || false,
|
||||
},
|
||||
@@ -1432,11 +1574,12 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId: input.orderId,
|
||||
action: 'REFUND_SUCCESS',
|
||||
action: finalStatus === ORDER_STATUS.PARTIALLY_REFUNDED ? 'PARTIAL_REFUND_SUCCESS' : 'REFUND_SUCCESS',
|
||||
detail: JSON.stringify({
|
||||
rechargeAmount,
|
||||
refundAmount,
|
||||
reason: input.reason,
|
||||
gatewayRefundAmount,
|
||||
reason: refundReason,
|
||||
force: input.force,
|
||||
deductBalance,
|
||||
balanceDeducted: plan.balanceAmount,
|
||||
|
||||
+48
-2
@@ -31,7 +31,9 @@ export interface OrderDisplayState {
|
||||
const CLOSED_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.EXPIRED,
|
||||
ORDER_STATUS.CANCELLED,
|
||||
ORDER_STATUS.REFUND_REQUESTED,
|
||||
ORDER_STATUS.REFUNDING,
|
||||
ORDER_STATUS.PARTIALLY_REFUNDED,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
@@ -78,6 +80,51 @@ export function deriveOrderState(order: OrderStatusLike): DerivedOrderState {
|
||||
export function getOrderDisplayState(
|
||||
order: Pick<PublicOrderStatusSnapshot, 'status' | 'paymentSuccess' | 'rechargeSuccess' | 'rechargeStatus'>,
|
||||
): OrderDisplayState {
|
||||
if (order.status === ORDER_STATUS.REFUND_REQUESTED) {
|
||||
return {
|
||||
label: '申请中',
|
||||
color: 'text-violet-600',
|
||||
icon: '…',
|
||||
message: '退款申请已提交,等待管理员确认。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.REFUNDING) {
|
||||
return {
|
||||
label: '退款中',
|
||||
color: 'text-orange-600',
|
||||
icon: '⟳',
|
||||
message: '管理员已确认退款,正在处理退款,请稍候。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.PARTIALLY_REFUNDED) {
|
||||
return {
|
||||
label: '已部分退款',
|
||||
color: 'text-fuchsia-600',
|
||||
icon: '✓',
|
||||
message: '订单已完成部分退款。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.REFUNDED) {
|
||||
return {
|
||||
label: '已退款',
|
||||
color: 'text-purple-600',
|
||||
icon: '✓',
|
||||
message: '订单已完成退款。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === ORDER_STATUS.REFUND_FAILED) {
|
||||
return {
|
||||
label: '退款失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '退款处理失败,请联系管理员。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeSuccess || order.rechargeStatus === 'success') {
|
||||
return {
|
||||
label: '充值成功',
|
||||
@@ -102,8 +149,7 @@ export function getOrderDisplayState(
|
||||
label: '支付成功',
|
||||
color: 'text-amber-600',
|
||||
icon: '!',
|
||||
message:
|
||||
'支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
|
||||
message: '支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+53
-7
@@ -13,9 +13,26 @@ export interface MyOrder {
|
||||
status: string;
|
||||
paymentType: string;
|
||||
createdAt: string;
|
||||
orderType?: string;
|
||||
refundRequestedAt?: string | null;
|
||||
refundRequestReason?: string | null;
|
||||
refundAmount?: number | null;
|
||||
canRefundRequest?: boolean;
|
||||
}
|
||||
|
||||
export type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED';
|
||||
export type OrderStatusFilter =
|
||||
| 'ALL'
|
||||
| 'PENDING'
|
||||
| 'PAID'
|
||||
| 'COMPLETED'
|
||||
| 'REFUND_REQUESTED'
|
||||
| 'REFUNDING'
|
||||
| 'PARTIALLY_REFUNDED'
|
||||
| 'REFUNDED'
|
||||
| 'REFUND_FAILED'
|
||||
| 'CANCELLED'
|
||||
| 'EXPIRED'
|
||||
| 'FAILED';
|
||||
|
||||
const STATUS_TEXT_MAP: Record<Locale, Record<string, string>> = {
|
||||
zh: {
|
||||
@@ -23,24 +40,28 @@ const STATUS_TEXT_MAP: Record<Locale, Record<string, string>> = {
|
||||
[ORDER_STATUS.PAID]: '已支付',
|
||||
[ORDER_STATUS.RECHARGING]: '充值中',
|
||||
[ORDER_STATUS.COMPLETED]: '已完成',
|
||||
[ORDER_STATUS.REFUND_REQUESTED]: '申请中',
|
||||
[ORDER_STATUS.REFUNDING]: '退款中',
|
||||
[ORDER_STATUS.PARTIALLY_REFUNDED]: '已部分退款',
|
||||
[ORDER_STATUS.REFUNDED]: '已退款',
|
||||
[ORDER_STATUS.REFUND_FAILED]: '退款失败',
|
||||
[ORDER_STATUS.EXPIRED]: '已超时',
|
||||
[ORDER_STATUS.CANCELLED]: '已取消',
|
||||
[ORDER_STATUS.FAILED]: '失败',
|
||||
[ORDER_STATUS.REFUNDING]: '退款中',
|
||||
[ORDER_STATUS.REFUNDED]: '已退款',
|
||||
[ORDER_STATUS.REFUND_FAILED]: '退款失败',
|
||||
},
|
||||
en: {
|
||||
[ORDER_STATUS.PENDING]: 'Pending',
|
||||
[ORDER_STATUS.PAID]: 'Paid',
|
||||
[ORDER_STATUS.RECHARGING]: 'Recharging',
|
||||
[ORDER_STATUS.COMPLETED]: 'Completed',
|
||||
[ORDER_STATUS.REFUND_REQUESTED]: 'Requested',
|
||||
[ORDER_STATUS.REFUNDING]: 'Refunding',
|
||||
[ORDER_STATUS.PARTIALLY_REFUNDED]: 'Partially refunded',
|
||||
[ORDER_STATUS.REFUNDED]: 'Refunded',
|
||||
[ORDER_STATUS.REFUND_FAILED]: 'Refund failed',
|
||||
[ORDER_STATUS.EXPIRED]: 'Expired',
|
||||
[ORDER_STATUS.CANCELLED]: 'Cancelled',
|
||||
[ORDER_STATUS.FAILED]: 'Failed',
|
||||
[ORDER_STATUS.REFUNDING]: 'Refunding',
|
||||
[ORDER_STATUS.REFUNDED]: 'Refunded',
|
||||
[ORDER_STATUS.REFUND_FAILED]: 'Refund failed',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,6 +70,11 @@ const FILTER_OPTIONS_MAP: Record<Locale, { key: OrderStatusFilter; label: string
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待支付' },
|
||||
{ key: 'COMPLETED', label: '已完成' },
|
||||
{ key: 'REFUND_REQUESTED', label: '申请中' },
|
||||
{ key: 'REFUNDING', label: '退款中' },
|
||||
{ key: 'PARTIALLY_REFUNDED', label: '已部分退款' },
|
||||
{ key: 'REFUNDED', label: '已退款' },
|
||||
{ key: 'REFUND_FAILED', label: '退款失败' },
|
||||
{ key: 'CANCELLED', label: '已取消' },
|
||||
{ key: 'EXPIRED', label: '已超时' },
|
||||
],
|
||||
@@ -56,6 +82,11 @@ const FILTER_OPTIONS_MAP: Record<Locale, { key: OrderStatusFilter; label: string
|
||||
{ key: 'ALL', label: 'All' },
|
||||
{ key: 'PENDING', label: 'Pending' },
|
||||
{ key: 'COMPLETED', label: 'Completed' },
|
||||
{ key: 'REFUND_REQUESTED', label: 'Requested' },
|
||||
{ key: 'REFUNDING', label: 'Refunding' },
|
||||
{ key: 'PARTIALLY_REFUNDED', label: 'Partially refunded' },
|
||||
{ key: 'REFUNDED', label: 'Refunded' },
|
||||
{ key: 'REFUND_FAILED', label: 'Refund failed' },
|
||||
{ key: 'CANCELLED', label: 'Cancelled' },
|
||||
{ key: 'EXPIRED', label: 'Expired' },
|
||||
],
|
||||
@@ -262,6 +293,21 @@ export function getStatusBadgeClass(status: string, isDark: boolean): string {
|
||||
if (status === ORDER_STATUS.COMPLETED || status === ORDER_STATUS.PAID) {
|
||||
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
|
||||
}
|
||||
if (status === ORDER_STATUS.REFUND_REQUESTED) {
|
||||
return isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-violet-100 text-violet-700';
|
||||
}
|
||||
if (status === ORDER_STATUS.REFUNDING) {
|
||||
return isDark ? 'bg-orange-500/20 text-orange-200' : 'bg-orange-100 text-orange-700';
|
||||
}
|
||||
if (status === ORDER_STATUS.PARTIALLY_REFUNDED) {
|
||||
return isDark ? 'bg-fuchsia-500/20 text-fuchsia-200' : 'bg-fuchsia-100 text-fuchsia-700';
|
||||
}
|
||||
if (status === ORDER_STATUS.REFUNDED) {
|
||||
return isDark ? 'bg-purple-500/20 text-purple-200' : 'bg-purple-100 text-purple-700';
|
||||
}
|
||||
if (status === ORDER_STATUS.REFUND_FAILED) {
|
||||
return isDark ? 'bg-red-500/20 text-red-200' : 'bg-red-100 text-red-700';
|
||||
}
|
||||
if (status === ORDER_STATUS.PENDING) {
|
||||
return isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-700';
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ export async function subtractBalance(
|
||||
headers: await getHeaders(idempotencyKey),
|
||||
body: JSON.stringify({
|
||||
operation: 'subtract',
|
||||
amount,
|
||||
balance: amount,
|
||||
notes,
|
||||
}),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
@@ -293,7 +293,7 @@ export async function addBalance(userId: number, amount: number, notes: string,
|
||||
headers: await getHeaders(idempotencyKey),
|
||||
body: JSON.stringify({
|
||||
operation: 'add',
|
||||
amount,
|
||||
balance: amount,
|
||||
notes,
|
||||
}),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
|
||||
Reference in New Issue
Block a user