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:
erio
2026-04-03 20:03:29 +08:00
24 changed files with 912 additions and 322 deletions
+9
View File
@@ -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
+11
View File
@@ -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';
+5
View File
@@ -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
}
+3 -2
View File
@@ -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 () => {
+2 -2
View File
@@ -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');
});
+32 -19
View File
@@ -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}
+25
View File
@@ -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">
+1
View File
@@ -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) {
+3 -2
View File
@@ -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 }),
+2
View File
@@ -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);
}
}
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
</>
);
}
+29 -39
View File
@@ -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
View File
@@ -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 && (
+48 -6
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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';
}
+2 -2
View File
@@ -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),