diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 6779fef..755e4d6 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -13,6 +13,14 @@ export async function GET(request: NextRequest) { const env = getEnv(); const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(env.ENABLED_PAYMENT_TYPES)]); + // 收集 sublabel 覆盖(仅包含用户实际配置的项) + const sublabelOverrides: Record = {}; + if (env.PAYMENT_SUBLABEL_ALIPAY) sublabelOverrides.alipay = env.PAYMENT_SUBLABEL_ALIPAY; + if (env.PAYMENT_SUBLABEL_ALIPAY_DIRECT) sublabelOverrides.alipay_direct = env.PAYMENT_SUBLABEL_ALIPAY_DIRECT; + if (env.PAYMENT_SUBLABEL_WXPAY) sublabelOverrides.wxpay = env.PAYMENT_SUBLABEL_WXPAY; + if (env.PAYMENT_SUBLABEL_WXPAY_DIRECT) sublabelOverrides.wxpay_direct = env.PAYMENT_SUBLABEL_WXPAY_DIRECT; + if (env.PAYMENT_SUBLABEL_STRIPE) sublabelOverrides.stripe = env.PAYMENT_SUBLABEL_STRIPE; + return NextResponse.json({ user: { id: user.id, @@ -30,6 +38,7 @@ export async function GET(request: NextRequest) { env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null, + sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null, }, }); } catch (error) { diff --git a/src/app/pay/page.tsx b/src/app/pay/page.tsx index 89e9e68..24f6e85 100644 --- a/src/app/pay/page.tsx +++ b/src/app/pay/page.tsx @@ -7,7 +7,7 @@ import PaymentQRCode from '@/components/PaymentQRCode'; import OrderStatus from '@/components/OrderStatus'; import PayPageLayout from '@/components/PayPageLayout'; import MobileOrderList from '@/components/MobileOrderList'; -import { detectDeviceIsMobile, type UserInfo, type MyOrder } from '@/lib/pay-utils'; +import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils'; import type { MethodLimitInfo } from '@/components/PaymentForm'; interface OrderResult { @@ -111,6 +111,10 @@ function PayContent() { helpText: cfgData.config.helpText ?? null, stripePublishableKey: cfgData.config.stripePublishableKey ?? null, }); + // 应用自定义 sublabel + if (cfgData.config.sublabelOverrides) { + applySublabelOverrides(cfgData.config.sublabelOverrides); + } } } else if (cfgRes.status === 404) { setUserNotFound(true); diff --git a/src/components/MobileOrderList.tsx b/src/components/MobileOrderList.tsx index 18d5378..dc91576 100644 --- a/src/components/MobileOrderList.tsx +++ b/src/components/MobileOrderList.tsx @@ -6,6 +6,7 @@ import { formatStatus, formatCreatedAt, getStatusBadgeClass, + getPaymentDisplayInfo, type MyOrder, type OrderStatusFilter, } from '@/lib/pay-utils'; @@ -113,7 +114,10 @@ export default function MobileOrderList({
- {order.paymentType} + {(() => { + const { channel, provider } = getPaymentDisplayInfo(order.paymentType); + return provider ? `${channel} · ${provider}` : channel; + })()}
{formatCreatedAt(order.createdAt)} diff --git a/src/components/OrderTable.tsx b/src/components/OrderTable.tsx index 86ae63c..5f13445 100644 --- a/src/components/OrderTable.tsx +++ b/src/components/OrderTable.tsx @@ -1,4 +1,4 @@ -import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder } from '@/lib/pay-utils'; +import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils'; interface OrderTableProps { isDark: boolean; @@ -67,7 +67,21 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl >
#{order.id.slice(0, 12)}
¥{order.amount.toFixed(2)}
-
{order.paymentType}
+
+ {(() => { + const { channel, provider } = getPaymentDisplayInfo(order.paymentType); + return ( + <> + {channel} + {provider && ( + + {provider} + + )} + + ); + })()} +
{ + if (!shouldAutoRedirect || redirected) return; + const url = isRedirect ? payUrl! : mobileRedirectUrl!; + setRedirected(true); + // embedded iframe 不能 location.href 跳转,用 window.open + if (isEmbedded) { + window.open(url, '_blank'); + } else { + window.location.href = url; + } + }, [shouldAutoRedirect, redirected, isRedirect, payUrl, mobileRedirectUrl, isEmbedded]); + const qrPayload = useMemo(() => { if (isRedirect && !qrCode) return ''; const value = (qrCode || payUrl || '').trim(); @@ -448,31 +467,22 @@ export default function PaymentQRCode({ )}
- ) : isMobile && payUrl ? ( + ) : shouldAutoRedirect ? ( <> +
+
+ + 正在跳转到{channelLabel}... + +
- {channelLabel} - {`打开${channelLabel}支付`} - -

- {TEXT_H5_HINT} -

- - ) : isRedirect && payUrl ? ( - <> - {iconSrc && {channelLabel}} - {`前往${channelLabel}收银台`} + {redirected ? `未跳转?点击前往${channelLabel}` : `前往${channelLabel}支付`}

{TEXT_H5_HINT} diff --git a/src/components/admin/OrderDetail.tsx b/src/components/admin/OrderDetail.tsx index 06cc7c6..b9e0901 100644 --- a/src/components/admin/OrderDetail.tsx +++ b/src/components/admin/OrderDetail.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getPaymentTypeLabel } from '@/lib/pay-utils'; +import { getPaymentDisplayInfo } from '@/lib/pay-utils'; interface AuditLog { id: string; @@ -55,7 +55,8 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) { label: 'Payment OK', value: order.paymentSuccess ? 'yes' : 'no' }, { label: 'Recharge OK', value: order.rechargeSuccess ? 'yes' : 'no' }, { label: 'Recharge Status', value: order.rechargeStatus || '-' }, - { label: '支付方式', value: getPaymentTypeLabel(order.paymentType) }, + { label: '支付渠道', value: getPaymentDisplayInfo(order.paymentType).channel }, + { label: '提供商', value: getPaymentDisplayInfo(order.paymentType).provider || '-' }, { label: '充值码', value: order.rechargeCode }, { label: '支付单号', value: order.paymentTradeNo || '-' }, { label: '客户端IP', value: order.clientIp || '-' }, diff --git a/src/components/admin/OrderTable.tsx b/src/components/admin/OrderTable.tsx index a2db253..ea2f7f7 100644 --- a/src/components/admin/OrderTable.tsx +++ b/src/components/admin/OrderTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getPaymentTypeLabel } from '@/lib/pay-utils'; +import { getPaymentDisplayInfo } from '@/lib/pay-utils'; interface Order { id: string; @@ -94,7 +94,21 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da {statusInfo.label} - {getPaymentTypeLabel(order.paymentType)} + + {(() => { + const { channel, provider } = getPaymentDisplayInfo(order.paymentType); + return ( + <> + {channel} + {provider && ( + + {provider} + + )} + + ); + })()} + {order.srcHost || '-'} {new Date(order.createdAt).toLocaleString('zh-CN')} diff --git a/src/lib/config.ts b/src/lib/config.ts index de8716a..d9ea196 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -89,6 +89,13 @@ const envSchema = z.object({ NEXT_PUBLIC_APP_URL: z.string().url(), PAY_HELP_IMAGE_URL: optionalTrimmedString, PAY_HELP_TEXT: optionalTrimmedString, + + // ── 支付方式前端描述(sublabel)覆盖,不设置则使用默认值 ── + PAYMENT_SUBLABEL_ALIPAY: optionalTrimmedString, + PAYMENT_SUBLABEL_ALIPAY_DIRECT: optionalTrimmedString, + PAYMENT_SUBLABEL_WXPAY: optionalTrimmedString, + PAYMENT_SUBLABEL_WXPAY_DIRECT: optionalTrimmedString, + PAYMENT_SUBLABEL_STRIPE: optionalTrimmedString, }); export type Env = z.infer; diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..5633fa8 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,51 @@ +/** 订单状态 */ +export const ORDER_STATUS = { + PENDING: 'PENDING', + PAID: 'PAID', + RECHARGING: 'RECHARGING', + COMPLETED: 'COMPLETED', + EXPIRED: 'EXPIRED', + CANCELLED: 'CANCELLED', + FAILED: 'FAILED', + REFUNDING: 'REFUNDING', + REFUNDED: 'REFUNDED', + REFUND_FAILED: 'REFUND_FAILED', +} as const; + +export type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS]; + +/** 终态状态集合(不再轮询) */ +export const TERMINAL_STATUSES = new Set([ + ORDER_STATUS.COMPLETED, + ORDER_STATUS.FAILED, + ORDER_STATUS.CANCELLED, + ORDER_STATUS.EXPIRED, + ORDER_STATUS.REFUNDED, + ORDER_STATUS.REFUND_FAILED, +]); + +/** 退款相关状态 */ +export const REFUND_STATUSES = new Set([ + ORDER_STATUS.REFUNDING, + ORDER_STATUS.REFUNDED, + ORDER_STATUS.REFUND_FAILED, +]); + +/** 支付方式标识 */ +export const PAYMENT_TYPE = { + ALIPAY: 'alipay', + ALIPAY_DIRECT: 'alipay_direct', + WXPAY: 'wxpay', + WXPAY_DIRECT: 'wxpay_direct', + STRIPE: 'stripe', +} as const; + +/** 支付方式前缀(用于 startsWith 判断) */ +export const PAYMENT_PREFIX = { + ALIPAY: 'alipay', + WXPAY: 'wxpay', + STRIPE: 'stripe', +} as const; + +/** 需要页面跳转(而非二维码)的支付方式 */ +export const REDIRECT_PAYMENT_TYPES = new Set([PAYMENT_TYPE.ALIPAY_DIRECT]); diff --git a/src/lib/order/limits.ts b/src/lib/order/limits.ts index 71cfc2a..6f1d282 100644 --- a/src/lib/order/limits.ts +++ b/src/lib/order/limits.ts @@ -1,5 +1,6 @@ import { prisma } from '@/lib/db'; import { getEnv } from '@/lib/config'; +import { ORDER_STATUS } from '@/lib/constants'; import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; import { getMethodFeeRate } from './fee'; @@ -72,7 +73,7 @@ export async function queryMethodLimits(paymentTypes: string[]): Promise= MAX_PENDING_ORDERS) { throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429); @@ -57,7 +58,7 @@ export async function createOrder(input: CreateOrderInput): Promise }); if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); - if (order.status !== 'PENDING') throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); + if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400); return cancelOrderCore({ orderId: order.id, paymentTradeNo: order.paymentTradeNo, paymentType: order.paymentType, - finalStatus: 'CANCELLED', + finalStatus: ORDER_STATUS.CANCELLED, operator: 'admin', auditDetail: 'Admin cancelled order', }); @@ -330,10 +331,10 @@ export async function confirmPayment(input: { const result = await prisma.order.updateMany({ where: { id: order.id, - status: { in: ['PENDING', 'EXPIRED'] }, + status: { in: [ORDER_STATUS.PENDING, ORDER_STATUS.EXPIRED] }, }, data: { - status: 'PAID', + status: ORDER_STATUS.PAID, amount: paidAmount, paymentTradeNo: input.tradeNo, paidAt: new Date(), @@ -392,13 +393,13 @@ export async function executeRecharge(orderId: string): Promise { if (!order) { throw new OrderError('NOT_FOUND', 'Order not found', 404); } - if (order.status === 'COMPLETED') { + if (order.status === ORDER_STATUS.COMPLETED) { return; } if (isRefundStatus(order.status)) { throw new OrderError('INVALID_STATUS', 'Refund-related order cannot recharge', 400); } - if (order.status !== 'PAID' && order.status !== 'FAILED') { + if (order.status !== ORDER_STATUS.PAID && order.status !== ORDER_STATUS.FAILED) { throw new OrderError('INVALID_STATUS', `Order cannot recharge in status ${order.status}`, 400); } @@ -412,7 +413,7 @@ export async function executeRecharge(orderId: string): Promise { await prisma.order.update({ where: { id: orderId }, - data: { status: 'COMPLETED', completedAt: new Date() }, + data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() }, }); await prisma.auditLog.create({ @@ -427,7 +428,7 @@ export async function executeRecharge(orderId: string): Promise { await prisma.order.update({ where: { id: orderId }, data: { - status: 'FAILED', + status: ORDER_STATUS.FAILED, failedAt: new Date(), failedReason: error instanceof Error ? error.message : String(error), }, @@ -455,15 +456,15 @@ function assertRetryAllowed(order: { status: string; paidAt: Date | null }): voi throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400); } - if (order.status === 'FAILED' || order.status === 'PAID') { + if (order.status === ORDER_STATUS.FAILED || order.status === ORDER_STATUS.PAID) { return; } - if (order.status === 'RECHARGING') { + if (order.status === ORDER_STATUS.RECHARGING) { throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409); } - if (order.status === 'COMPLETED') { + if (order.status === ORDER_STATUS.COMPLETED) { throw new OrderError('INVALID_STATUS', 'Order already completed', 400); } @@ -490,10 +491,10 @@ export async function retryRecharge(orderId: string): Promise { const result = await prisma.order.updateMany({ where: { id: orderId, - status: { in: ['FAILED', 'PAID'] }, + status: { in: [ORDER_STATUS.FAILED, ORDER_STATUS.PAID] }, paidAt: { not: null }, }, - data: { status: 'PAID', failedAt: null, failedReason: null }, + data: { status: ORDER_STATUS.PAID, failedAt: null, failedReason: null }, }); if (result.count === 0) { @@ -511,7 +512,7 @@ export async function retryRecharge(orderId: string): Promise { } const derived = deriveOrderState(latest); - if (derived.rechargeStatus === 'recharging' || latest.status === 'PAID') { + if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) { throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409); } @@ -553,7 +554,7 @@ export interface RefundResult { export async function processRefund(input: RefundInput): Promise { const order = await prisma.order.findUnique({ where: { id: input.orderId } }); if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404); - if (order.status !== 'COMPLETED') { + if (order.status !== ORDER_STATUS.COMPLETED) { throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400); } @@ -580,8 +581,8 @@ export async function processRefund(input: RefundInput): Promise { } const lockResult = await prisma.order.updateMany({ - where: { id: input.orderId, status: 'COMPLETED' }, - data: { status: 'REFUNDING' }, + where: { id: input.orderId, status: ORDER_STATUS.COMPLETED }, + data: { status: ORDER_STATUS.REFUNDING }, }); if (lockResult.count === 0) { throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409); @@ -609,7 +610,7 @@ export async function processRefund(input: RefundInput): Promise { await prisma.order.update({ where: { id: input.orderId }, data: { - status: 'REFUNDED', + status: ORDER_STATUS.REFUNDED, refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)), refundReason: input.reason || null, refundAt: new Date(), @@ -631,7 +632,7 @@ export async function processRefund(input: RefundInput): Promise { await prisma.order.update({ where: { id: input.orderId }, data: { - status: 'REFUND_FAILED', + status: ORDER_STATUS.REFUND_FAILED, failedAt: new Date(), failedReason: error instanceof Error ? error.message : String(error), }, diff --git a/src/lib/order/status.ts b/src/lib/order/status.ts index 237bdd6..b5b566b 100644 --- a/src/lib/order/status.ts +++ b/src/lib/order/status.ts @@ -1,3 +1,5 @@ +import { ORDER_STATUS, REFUND_STATUSES } from '@/lib/constants'; + export type RechargeStatus = 'not_paid' | 'paid_pending' | 'recharging' | 'success' | 'failed' | 'closed'; export interface OrderStatusLike { @@ -6,9 +8,13 @@ export interface OrderStatusLike { completedAt?: Date | string | null; } -const CLOSED_STATUSES = new Set(['EXPIRED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED']); - -const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']); +const CLOSED_STATUSES = new Set([ + ORDER_STATUS.EXPIRED, + ORDER_STATUS.CANCELLED, + ORDER_STATUS.REFUNDING, + ORDER_STATUS.REFUNDED, + ORDER_STATUS.REFUND_FAILED, +]); function hasDate(value: Date | string | null | undefined): boolean { return Boolean(value); @@ -19,7 +25,7 @@ export function isRefundStatus(status: string): boolean { } export function isRechargeRetryable(order: OrderStatusLike): boolean { - return hasDate(order.paidAt) && order.status === 'FAILED' && !isRefundStatus(order.status); + return hasDate(order.paidAt) && order.status === ORDER_STATUS.FAILED && !isRefundStatus(order.status); } export function deriveOrderState(order: OrderStatusLike): { @@ -28,17 +34,17 @@ export function deriveOrderState(order: OrderStatusLike): { rechargeStatus: RechargeStatus; } { const paymentSuccess = hasDate(order.paidAt); - const rechargeSuccess = hasDate(order.completedAt) || order.status === 'COMPLETED'; + const rechargeSuccess = hasDate(order.completedAt) || order.status === ORDER_STATUS.COMPLETED; if (rechargeSuccess) { return { paymentSuccess, rechargeSuccess: true, rechargeStatus: 'success' }; } - if (order.status === 'RECHARGING') { + if (order.status === ORDER_STATUS.RECHARGING) { return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'recharging' }; } - if (order.status === 'FAILED') { + if (order.status === ORDER_STATUS.FAILED) { return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'failed' }; } diff --git a/src/lib/order/timeout.ts b/src/lib/order/timeout.ts index 8e8b10a..ad5288d 100644 --- a/src/lib/order/timeout.ts +++ b/src/lib/order/timeout.ts @@ -1,4 +1,5 @@ import { prisma } from '@/lib/db'; +import { ORDER_STATUS } from '@/lib/constants'; import { cancelOrderCore } from './service'; const INTERVAL_MS = 30_000; // 30 seconds @@ -7,7 +8,7 @@ let timer: ReturnType | null = null; export async function expireOrders(): Promise { const orders = await prisma.order.findMany({ where: { - status: 'PENDING', + status: ORDER_STATUS.PENDING, expiresAt: { lt: new Date() }, }, select: { @@ -27,7 +28,7 @@ export async function expireOrders(): Promise { orderId: order.id, paymentTradeNo: order.paymentTradeNo, paymentType: order.paymentType, - finalStatus: 'EXPIRED', + finalStatus: ORDER_STATUS.EXPIRED, operator: 'timeout', auditDetail: 'Order expired', }); diff --git a/src/lib/pay-utils.ts b/src/lib/pay-utils.ts index 52bcac0..fee9849 100644 --- a/src/lib/pay-utils.ts +++ b/src/lib/pay-utils.ts @@ -1,3 +1,10 @@ +import { + ORDER_STATUS, + PAYMENT_TYPE, + PAYMENT_PREFIX, + REDIRECT_PAYMENT_TYPES, +} from './constants'; + export interface UserInfo { id?: number; username: string; @@ -15,16 +22,16 @@ export interface MyOrder { export type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED'; export const STATUS_TEXT_MAP: Record = { - PENDING: '待支付', - PAID: '已支付', - RECHARGING: '充值中', - COMPLETED: '已完成', - EXPIRED: '已超时', - CANCELLED: '已取消', - FAILED: '失败', - REFUNDING: '退款中', - REFUNDED: '已退款', - REFUND_FAILED: '退款失败', + [ORDER_STATUS.PENDING]: '待支付', + [ORDER_STATUS.PAID]: '已支付', + [ORDER_STATUS.RECHARGING]: '充值中', + [ORDER_STATUS.COMPLETED]: '已完成', + [ORDER_STATUS.EXPIRED]: '已超时', + [ORDER_STATUS.CANCELLED]: '已取消', + [ORDER_STATUS.FAILED]: '失败', + [ORDER_STATUS.REFUNDING]: '退款中', + [ORDER_STATUS.REFUNDED]: '已退款', + [ORDER_STATUS.REFUND_FAILED]: '退款失败', }; export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [ @@ -64,8 +71,12 @@ export function formatCreatedAt(value: string): string { } export interface PaymentTypeMeta { + /** 支付渠道名(用户看到的:支付宝 / 微信支付 / Stripe) */ label: string; + /** 选择器中的辅助说明(易支付 / 官方 / 信用卡 / 借记卡) */ sublabel?: string; + /** 提供商名称(易支付 / 支付宝 / 微信支付 / Stripe) */ + provider: string; color: string; selectedBorder: string; selectedBg: string; @@ -79,9 +90,10 @@ export interface PaymentTypeMeta { } export const PAYMENT_TYPE_META: Record = { - alipay: { + [PAYMENT_TYPE.ALIPAY]: { label: '支付宝', sublabel: '易支付', + provider: '易支付', color: '#00AEEF', selectedBorder: 'border-cyan-400', selectedBg: 'bg-cyan-50', @@ -90,9 +102,10 @@ export const PAYMENT_TYPE_META: Record = { chartBar: { light: 'bg-cyan-500', dark: 'bg-cyan-400' }, buttonClass: 'bg-[#00AEEF] hover:bg-[#009dd6] active:bg-[#008cbe]', }, - alipay_direct: { + [PAYMENT_TYPE.ALIPAY_DIRECT]: { label: '支付宝', - sublabel: '官方直连', + sublabel: '官方', + provider: '支付宝', color: '#1677FF', selectedBorder: 'border-blue-500', selectedBg: 'bg-blue-50', @@ -101,9 +114,10 @@ export const PAYMENT_TYPE_META: Record = { chartBar: { light: 'bg-blue-500', dark: 'bg-blue-400' }, buttonClass: 'bg-[#1677FF] hover:bg-[#0958d9] active:bg-[#003eb3]', }, - wxpay: { + [PAYMENT_TYPE.WXPAY]: { label: '微信支付', sublabel: '易支付', + provider: '易支付', color: '#2BB741', selectedBorder: 'border-green-500', selectedBg: 'bg-green-50', @@ -112,9 +126,10 @@ export const PAYMENT_TYPE_META: Record = { chartBar: { light: 'bg-green-500', dark: 'bg-green-400' }, buttonClass: 'bg-[#2BB741] hover:bg-[#24a038] active:bg-[#1d8a2f]', }, - wxpay_direct: { + [PAYMENT_TYPE.WXPAY_DIRECT]: { label: '微信支付', - sublabel: '官方直连', + sublabel: '官方', + provider: '微信支付', color: '#07C160', selectedBorder: 'border-green-600', selectedBg: 'bg-green-50', @@ -123,9 +138,10 @@ export const PAYMENT_TYPE_META: Record = { chartBar: { light: 'bg-emerald-500', dark: 'bg-emerald-400' }, buttonClass: 'bg-[#07C160] hover:bg-[#06ad56] active:bg-[#05994c]', }, - stripe: { + [PAYMENT_TYPE.STRIPE]: { label: 'Stripe', sublabel: '信用卡 / 借记卡', + provider: 'Stripe', color: '#635bff', selectedBorder: 'border-[#635bff]', selectedBg: 'bg-[#635bff]/10', @@ -135,25 +151,32 @@ export const PAYMENT_TYPE_META: Record = { }, }; -/** 获取支付方式的显示名称(如 '支付宝(官方直连)') */ +/** 获取支付方式的显示名称(如 '支付宝(官方)') */ export function getPaymentTypeLabel(type: string): string { const meta = PAYMENT_TYPE_META[type]; if (!meta) return type; return meta.sublabel ? `${meta.label}(${meta.sublabel})` : meta.label; } +/** 获取支付渠道和提供商的结构化信息 */ +export function getPaymentDisplayInfo(type: string): { channel: string; provider: string } { + const meta = PAYMENT_TYPE_META[type]; + if (!meta) return { channel: type, provider: '' }; + return { channel: meta.label, provider: meta.provider }; +} + /** 获取基础支付方式图标类型(alipay_direct → alipay) */ export function getPaymentIconType(type: string): string { - if (type.startsWith('alipay')) return 'alipay'; - if (type.startsWith('wxpay')) return 'wxpay'; - if (type.startsWith('stripe')) return 'stripe'; + if (type.startsWith(PAYMENT_PREFIX.ALIPAY)) return PAYMENT_PREFIX.ALIPAY; + if (type.startsWith(PAYMENT_PREFIX.WXPAY)) return PAYMENT_PREFIX.WXPAY; + if (type.startsWith(PAYMENT_PREFIX.STRIPE)) return PAYMENT_PREFIX.STRIPE; return type; } /** 获取支付方式的元数据,带合理的 fallback */ export function getPaymentMeta(type: string): PaymentTypeMeta { const base = getPaymentIconType(type); - return PAYMENT_TYPE_META[type] || PAYMENT_TYPE_META[base] || PAYMENT_TYPE_META.alipay; + return PAYMENT_TYPE_META[type] || PAYMENT_TYPE_META[base] || PAYMENT_TYPE_META[PAYMENT_TYPE.ALIPAY]; } /** 获取支付方式图标路径 */ @@ -168,30 +191,40 @@ export function getPaymentChannelLabel(type: string): string { /** 支付类型谓词函数 */ export function isStripeType(type: string | undefined | null): boolean { - return !!type?.startsWith('stripe'); + return !!type?.startsWith(PAYMENT_PREFIX.STRIPE); } export function isWxpayType(type: string | undefined | null): boolean { - return !!type?.startsWith('wxpay'); + return !!type?.startsWith(PAYMENT_PREFIX.WXPAY); } export function isAlipayType(type: string | undefined | null): boolean { - return !!type?.startsWith('alipay'); + return !!type?.startsWith(PAYMENT_PREFIX.ALIPAY); } -/** alipay_direct 使用页面跳转而非二维码 */ +/** 该支付方式需要页面跳转(而非二维码) */ export function isRedirectPayment(type: string | undefined | null): boolean { - return type === 'alipay_direct'; + return !!type && REDIRECT_PAYMENT_TYPES.has(type); +} + +/** 用自定义 sublabel 覆盖默认值 */ +export function applySublabelOverrides(overrides: Record): void { + for (const [type, sublabel] of Object.entries(overrides)) { + if (PAYMENT_TYPE_META[type]) { + PAYMENT_TYPE_META[type] = { ...PAYMENT_TYPE_META[type], sublabel }; + } + } } export function getStatusBadgeClass(status: string, isDark: boolean): string { - if (['COMPLETED', 'PAID'].includes(status)) { + 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 === 'PENDING') { + if (status === ORDER_STATUS.PENDING) { return isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-700'; } - if (['CANCELLED', 'EXPIRED', 'FAILED'].includes(status)) { + const GREY_STATUSES = new Set([ORDER_STATUS.CANCELLED, ORDER_STATUS.EXPIRED, ORDER_STATUS.FAILED]); + if (GREY_STATUSES.has(status)) { return isDark ? 'bg-slate-600 text-slate-200' : 'bg-slate-100 text-slate-700'; } return isDark ? 'bg-slate-700 text-slate-200' : 'bg-slate-100 text-slate-700';