From 0c8b402f3e11611b72cb1f53ea2dbaa47744c66d Mon Sep 17 00:00:00 2001 From: JetSprow Date: Fri, 1 May 2026 03:41:30 +1000 Subject: [PATCH] feat: polish wallet recharge cards --- src/actions/admin/recharge-cards.ts | 51 +++++++ src/actions/user/wallet.ts | 35 ++++- .../_components/recharge-card-actions.tsx | 127 +++++++++++++++++ .../(admin)/admin/commerce/commerce-data.ts | 12 +- src/app/(admin)/admin/commerce/page.tsx | 128 ++++++++++++++---- .../_components/recharge-orders-table.tsx | 98 ++++++++++++++ src/app/(admin)/admin/orders/orders-data.ts | 43 ++++++ src/app/(admin)/admin/orders/page.tsx | 124 +++++++++++++---- .../wallet/_components/wallet-actions.tsx | 16 ++- .../_components/wallet-balance-card.tsx | 46 +++++++ .../wallet/_components/wallet-events.ts | 5 + .../_components/wallet-recharge-actions.tsx | 34 +++++ src/app/(user)/wallet/page.tsx | 27 +--- .../recharge/[id]/recharge-pay-client.tsx | 53 +++++++- src/services/wallet.ts | 78 ++++++++--- 15 files changed, 774 insertions(+), 103 deletions(-) create mode 100644 src/app/(admin)/admin/commerce/_components/recharge-card-actions.tsx create mode 100644 src/app/(admin)/admin/orders/_components/recharge-orders-table.tsx create mode 100644 src/app/(user)/wallet/_components/wallet-balance-card.tsx create mode 100644 src/app/(user)/wallet/_components/wallet-events.ts create mode 100644 src/app/(user)/wallet/_components/wallet-recharge-actions.tsx diff --git a/src/actions/admin/recharge-cards.ts b/src/actions/admin/recharge-cards.ts index 5fdbe99..93b8d11 100644 --- a/src/actions/admin/recharge-cards.ts +++ b/src/actions/admin/recharge-cards.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { prisma } from "@/lib/prisma"; import { requireAdmin } from "@/lib/require-auth"; import { actorFromSession, recordAuditLog } from "@/services/audit"; import { createRechargeCards } from "@/services/wallet"; @@ -62,3 +63,53 @@ export async function createAdminRechargeCards(formData: FormData) { revalidatePath("/admin/plans"); revalidatePath("/store"); } + +export async function deleteAdminRechargeCard(cardId: string) { + const session = await requireAdmin(); + const id = z.string().min(1, "充值卡不存在").parse(cardId); + const card = await prisma.rechargeCard.findUnique({ + where: { id }, + include: { + plan: { select: { id: true, name: true } }, + redeemedBy: { select: { email: true, name: true } }, + }, + }); + + if (!card) { + throw new Error("充值卡不存在或已被删除"); + } + + const releasesPlanStock = + card.type === "PLAN" + && card.status === "UNUSED" + && (!card.expiresAt || card.expiresAt > new Date()); + + await prisma.rechargeCard.delete({ where: { id } }); + + await recordAuditLog({ + actor: actorFromSession(session), + action: "recharge_card.delete", + targetType: "RechargeCard", + targetId: card.id, + targetLabel: card.code, + message: releasesPlanStock + ? `删除套餐充值卡 ${card.code},已释放套餐库存` + : `删除充值卡 ${card.code}`, + metadata: { + code: card.code, + type: card.type, + status: card.status, + planId: card.planId, + planName: card.plan?.name ?? null, + redeemedBy: card.redeemedBy?.email ?? null, + redeemedAt: card.redeemedAt?.toISOString() ?? null, + releasesPlanStock, + }, + }); + + revalidatePath("/admin/commerce"); + revalidatePath("/admin/plans"); + revalidatePath("/store"); + + return { releasesPlanStock }; +} diff --git a/src/actions/user/wallet.ts b/src/actions/user/wallet.ts index f9bf20f..8f726fc 100644 --- a/src/actions/user/wallet.ts +++ b/src/actions/user/wallet.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { prisma } from "@/lib/prisma"; import { requireAuth } from "@/lib/require-auth"; import { createWalletRechargeOrder, redeemRechargeCard } from "@/services/wallet"; @@ -23,8 +24,40 @@ export async function createWalletRecharge(formData: FormData) { export async function redeemWalletCard(formData: FormData) { const session = await requireAuth(); const data = redeemSchema.parse(Object.fromEntries(formData)); - await redeemRechargeCard(session.user.id, data.code); + const result = await redeemRechargeCard(session.user.id, data.code); revalidatePath("/wallet"); revalidatePath("/subscriptions"); revalidatePath("/dashboard"); + return result; +} + +export async function cancelWalletRecharge(rechargeId: string) { + const session = await requireAuth(); + const recharge = await prisma.walletRechargeOrder.findFirst({ + where: { id: rechargeId, userId: session.user.id }, + select: { id: true, status: true }, + }); + + if (!recharge) { + throw new Error("充值订单不存在"); + } + if (recharge.status !== "PENDING") { + throw new Error("这笔充值订单已经不在待支付状态"); + } + + await prisma.walletRechargeOrder.update({ + where: { id: rechargeId }, + data: { + status: "CANCELLED", + paymentMethod: null, + paymentRef: null, + paymentUrl: null, + tradeNo: null, + expireAt: null, + note: null, + }, + }); + + revalidatePath("/wallet"); + revalidatePath(`/wallet/recharge/${rechargeId}`); } diff --git a/src/app/(admin)/admin/commerce/_components/recharge-card-actions.tsx b/src/app/(admin)/admin/commerce/_components/recharge-card-actions.tsx new file mode 100644 index 0000000..56c7c99 --- /dev/null +++ b/src/app/(admin)/admin/commerce/_components/recharge-card-actions.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Eye, Trash2 } from "lucide-react"; +import { deleteAdminRechargeCard } from "@/actions/admin/recharge-cards"; +import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; +import { CopyButton } from "@/components/shared/copy-button"; +import { StatusBadge, type StatusTone } from "@/components/shared/status-badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export interface RechargeCardActionItem { + id: string; + code: string; + type: "BALANCE" | "PLAN"; + typeLabel: string; + status: "UNUSED" | "REDEEMED" | "EXPIRED" | "DISABLED"; + statusLabel: string; + statusTone: StatusTone; + valueLabel: string; + batchName: string | null; + createdByLabel: string; + redeemedByLabel: string; + createdAtLabel: string; + updatedAtLabel: string; + expiresAtLabel: string; + redeemedAtLabel: string; + transactionLabel: string | null; + releasesPlanStock: boolean; +} + +function DetailItem({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export function RechargeCardActions({ card }: { card: RechargeCardActionItem }) { + const router = useRouter(); + const deleteDescription = card.releasesPlanStock + ? "这张套餐卡尚未兑换,删除后会释放 1 个套餐库存。已兑换记录不会受影响。" + : card.status === "REDEEMED" + ? "删除只移除卡密记录,不会回滚已兑换的余额或套餐。" + : "删除后卡密不可恢复,请确认不再需要保留。"; + + const details = [ + { label: "卡密", value: card.code, mono: true }, + { label: "类型", value: card.typeLabel }, + { label: card.type === "BALANCE" ? "充值金额" : "绑定套餐", value: card.valueLabel }, + { label: "批次", value: card.batchName ?? "未设置" }, + { label: "创建人", value: card.createdByLabel }, + { label: "创建时间", value: card.createdAtLabel }, + { label: "有效期", value: card.expiresAtLabel }, + { label: "更新时间", value: card.updatedAtLabel }, + { label: "兑换人", value: card.redeemedByLabel }, + { label: "兑换时间", value: card.redeemedAtLabel }, + ]; + + return ( +
+ + + }> + + 详情 + + + +
+ 充值卡详情 + {card.statusLabel} +
+ {card.valueLabel} +
+ +
+ {details.map((item) => ( + + ))} +
+ {card.transactionLabel && ( +
+ {card.transactionLabel} +
+ )} +
+
+
+ { + await deleteAdminRechargeCard(card.id); + }} + onSuccess={() => router.refresh()} + > + + 删除 + +
+ ); +} diff --git a/src/app/(admin)/admin/commerce/commerce-data.ts b/src/app/(admin)/admin/commerce/commerce-data.ts index ac507ec..72e91c8 100644 --- a/src/app/(admin)/admin/commerce/commerce-data.ts +++ b/src/app/(admin)/admin/commerce/commerce-data.ts @@ -34,7 +34,17 @@ export async function getCommerceData( orderBy: { createdAt: "desc" }, include: { plan: { select: { name: true, type: true } }, - redeemedBy: { select: { email: true } }, + redeemedBy: { select: { email: true, name: true } }, + createdBy: { select: { email: true, name: true } }, + transactions: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { + amount: true, + balanceAfter: true, + createdAt: true, + }, + }, }, skip, take: pageSize, diff --git a/src/app/(admin)/admin/commerce/page.tsx b/src/app/(admin)/admin/commerce/page.tsx index 75ee816..ff14be1 100644 --- a/src/app/(admin)/admin/commerce/page.tsx +++ b/src/app/(admin)/admin/commerce/page.tsx @@ -2,10 +2,10 @@ import type { Metadata } from "next"; import { Gift, Sparkles, WalletCards } from "lucide-react"; import { createCoupon, createPromotionRule } from "@/actions/admin/commerce"; import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge"; -import { CopyButton } from "@/components/shared/copy-button"; import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell"; import { Pagination } from "@/components/shared/pagination"; import { PendingSubmitButton } from "@/components/shared/pending-submit-button"; +import type { StatusTone } from "@/components/shared/status-badge"; import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -14,6 +14,7 @@ import { formatDate } from "@/lib/utils"; import { getCommerceData } from "./commerce-data"; import { CommerceToggleButton } from "./_components/commerce-actions"; import { DiscountTypeSelect } from "./_components/discount-type-select"; +import { RechargeCardActions, type RechargeCardActionItem } from "./_components/recharge-card-actions"; import { RechargeCardForm } from "./_components/recharge-card-form"; function formatCouponDiscount(type: string, value: unknown) { @@ -36,6 +37,76 @@ const rechargeCardStatusLabels: Record = { DISABLED: "已停用", }; +const rechargeCardStatusTones: Record = { + UNUSED: "success", + REDEEMED: "info", + EXPIRED: "warning", + DISABLED: "danger", +}; + +function userLabel(user: { name: string | null; email: string } | null | undefined, fallback = "系统") { + if (!user) return fallback; + return user.name ? `${user.name} · ${user.email}` : user.email; +} + +function formatRechargeCardValue(card: { + type: "BALANCE" | "PLAN"; + balanceAmount: unknown; + plan: { name: string } | null; +}) { + return card.type === "BALANCE" + ? `¥${Number(card.balanceAmount ?? 0).toFixed(2)}` + : card.plan?.name ?? "套餐已删除"; +} + +type RechargeCardRow = { + id: string; + code: string; + type: "BALANCE" | "PLAN"; + status: "UNUSED" | "REDEEMED" | "EXPIRED" | "DISABLED"; + balanceAmount: unknown; + plan: { name: string } | null; + batchName: string | null; + expiresAt: Date | null; + redeemedAt: Date | null; + createdAt: Date; + updatedAt: Date; + createdBy: { name: string | null; email: string } | null; + redeemedBy: { name: string | null; email: string } | null; + transactions: Array<{ + amount: unknown; + balanceAfter: unknown; + createdAt: Date; + }>; +}; + +function formatRechargeCardAction(card: RechargeCardRow): RechargeCardActionItem { + const transaction = card.transactions[0] ?? null; + return { + id: card.id, + code: card.code, + type: card.type, + typeLabel: card.type === "BALANCE" ? "余额卡" : "套餐卡", + status: card.status, + statusLabel: rechargeCardStatusLabels[card.status] ?? "未知状态", + statusTone: rechargeCardStatusTones[card.status] ?? "neutral", + valueLabel: formatRechargeCardValue(card), + batchName: card.batchName, + createdByLabel: userLabel(card.createdBy), + redeemedByLabel: userLabel(card.redeemedBy, "未兑换"), + createdAtLabel: formatDate(card.createdAt), + updatedAtLabel: formatDate(card.updatedAt), + expiresAtLabel: card.expiresAt ? formatDate(card.expiresAt) : "永不过期", + redeemedAtLabel: card.redeemedAt ? formatDate(card.redeemedAt) : "未兑换", + transactionLabel: transaction + ? `余额入账 ¥${Number(transaction.amount).toFixed(2)} · 兑换后余额 ¥${Number(transaction.balanceAfter).toFixed(2)} · ${formatDate(transaction.createdAt)}` + : null, + releasesPlanStock: card.type === "PLAN" + && card.status === "UNUSED" + && (!card.expiresAt || card.expiresAt > new Date()), + }; +} + function normalizeCommerceTab(value: string | string[] | undefined) { return value === "manage" || value === "cards" ? value : "create"; } @@ -205,36 +276,35 @@ export default async function AdminCommercePage({
- {rechargeCards.map((card) => ( -
-
- - - -
-
-

{card.code}

- - {rechargeCardStatusLabels[card.status] ?? "未知状态"} - + {rechargeCards.map((card) => { + const actionCard = formatRechargeCardAction(card); + return ( +
+
+ + + +
+
+

{card.code}

+ + {actionCard.statusLabel} + +
+

+ {actionCard.typeLabel} · {actionCard.valueLabel} +

-

- {card.type === "BALANCE" - ? `余额卡 ¥${Number(card.balanceAmount ?? 0).toFixed(2)}` - : `套餐卡 ${card.plan?.name ?? "套餐已删除"}`} -

-
-
- {card.batchName && {card.batchName}} - {card.redeemedBy && {card.redeemedBy.email}} - {card.expiresAt ? `到期 ${formatDate(card.expiresAt)}` : "永不过期"} -
-
- -
-
- ))} +
+ {card.batchName && {card.batchName}} + {card.redeemedBy && {userLabel(card.redeemedBy, "已兑换")}} + {card.expiresAt ? `到期 ${formatDate(card.expiresAt)}` : "永不过期"} +
+ + + ); + })} {rechargeCards.length === 0 && (

暂无充值卡

)} diff --git a/src/app/(admin)/admin/orders/_components/recharge-orders-table.tsx b/src/app/(admin)/admin/orders/_components/recharge-orders-table.tsx new file mode 100644 index 0000000..aebbc13 --- /dev/null +++ b/src/app/(admin)/admin/orders/_components/recharge-orders-table.tsx @@ -0,0 +1,98 @@ +import { DataTableShell } from "@/components/admin/data-table-shell"; +import { + DataTable, + DataTableBody, + DataTableCell, + DataTableHead, + DataTableHeadCell, + DataTableHeaderRow, + DataTableRow, +} from "@/components/shared/data-table"; +import { OrderStatusBadge } from "@/components/shared/domain-badges"; +import { getPaymentProviderName } from "@/services/payment/catalog"; +import { formatDateShort } from "@/lib/utils"; +import type { AdminRechargeOrderRow } from "../orders-data"; + +interface RechargeOrdersTableProps { + rechargeOrders: AdminRechargeOrderRow[]; +} + +function formatAmount(amount: { toString(): string }) { + return `¥${Number(amount).toFixed(2)}`; +} + +function getPaymentLabel(provider: string | null) { + return provider ? getPaymentProviderName(provider) : "未选择支付"; +} + +export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps) { + return ( + ( +
+
+
+

{order.user.email}

+

{order.user.name || "未设置昵称"}

+
+ +
+
+
+

充值金额

+

{formatAmount(order.amount)}

+
+

+ {getPaymentLabel(order.paymentMethod)} · {order.tradeNo || "无交易号"} +

+

{formatDateShort(order.createdAt)}

+
+
+ ))} + > + + + + 用户 + 金额 + 支付 + 状态 + 备注 + 时间 + + + + {rechargeOrders.map((order) => ( + + +

{order.user.email}

+

{order.user.name || "未设置昵称"}

+
+ {formatAmount(order.amount)} + +
+

{getPaymentLabel(order.paymentMethod)}

+

+ {order.tradeNo || "—"} +

+
+
+ + + + + {order.note || order.paymentRef || "—"} + + + {formatDateShort(order.createdAt)} + +
+ ))} +
+
+
+ ); +} diff --git a/src/app/(admin)/admin/orders/orders-data.ts b/src/app/(admin)/admin/orders/orders-data.ts index eb31f0c..2a9a90a 100644 --- a/src/app/(admin)/admin/orders/orders-data.ts +++ b/src/app/(admin)/admin/orders/orders-data.ts @@ -11,6 +11,14 @@ export type AdminOrderRow = Prisma.OrderGetPayload<{ include: typeof adminOrderInclude; }>; +const adminRechargeOrderInclude = { + user: true, +} satisfies Prisma.WalletRechargeOrderInclude; + +export type AdminRechargeOrderRow = Prisma.WalletRechargeOrderGetPayload<{ + include: typeof adminRechargeOrderInclude; +}>; + export async function getAdminOrders( searchParams: Record, ) { @@ -52,3 +60,38 @@ export async function getAdminOrders( return { orders, total, page, pageSize, filters: { q, status, kind, reviewStatus } }; } + +export async function getAdminRechargeOrders( + searchParams: Record, +) { + const { page, skip, pageSize } = parsePage(searchParams); + const q = typeof searchParams.q === "string" ? searchParams.q.trim() : ""; + const status = typeof searchParams.status === "string" ? searchParams.status : ""; + + const where = { + ...(status ? { status: status as "PENDING" | "PAID" | "CANCELLED" | "REFUNDED" } : {}), + ...(q + ? { + OR: [ + { user: { email: { contains: q } } }, + { user: { name: { contains: q } } }, + { tradeNo: { contains: q } }, + { paymentRef: { contains: q } }, + ], + } + : {}), + } satisfies Prisma.WalletRechargeOrderWhereInput; + + const [rechargeOrders, total] = await Promise.all([ + prisma.walletRechargeOrder.findMany({ + where, + include: adminRechargeOrderInclude, + orderBy: { createdAt: "desc" }, + skip, + take: pageSize, + }), + prisma.walletRechargeOrder.count({ where }), + ]); + + return { rechargeOrders, total, page, pageSize, filters: { q, status } }; +} diff --git a/src/app/(admin)/admin/orders/page.tsx b/src/app/(admin)/admin/orders/page.tsx index 5089fcb..15463c8 100644 --- a/src/app/(admin)/admin/orders/page.tsx +++ b/src/app/(admin)/admin/orders/page.tsx @@ -1,21 +1,56 @@ import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import Link from "next/link"; import { AdminFilterBar } from "@/components/admin/filter-bar"; import { PageHeader, PageShell } from "@/components/shared/page-shell"; import { Pagination } from "@/components/shared/pagination"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { OrdersTable } from "./_components/orders-table"; -import { getAdminOrders } from "./orders-data"; +import { RechargeOrdersTable } from "./_components/recharge-orders-table"; +import { getAdminOrders, getAdminRechargeOrders } from "./orders-data"; export const metadata: Metadata = { title: "订单管理", description: "跟踪订单状态、审查结果与支付记录。", }; +function normalizeOrdersTab(value: string | string[] | undefined) { + return value === "recharge" ? "recharge" : "orders"; +} + +function OrdersTabLink({ + href, + active, + children, +}: { + href: string; + active: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + export default async function OrdersPage({ searchParams, }: { searchParams: Promise>; }) { - const { orders, total, page, pageSize, filters } = await getAdminOrders(await searchParams); + const params = await searchParams; + const activeTab = normalizeOrdersTab(params.tab); + const orderData = activeTab === "orders" ? await getAdminOrders(params) : null; + const rechargeData = activeTab === "recharge" ? await getAdminRechargeOrders(params) : null; + const filters = activeTab === "orders" ? orderData!.filters : rechargeData!.filters; return ( @@ -23,45 +58,78 @@ export default async function OrdersPage({ eyebrow="商品与订单" title="订单管理" /> +
+ + 商品订单 + + + 充值订单 + +
- - + > + + + {activeTab === "orders" ? ( + <> + + + + ) : ( + <> + + + + )}
); } diff --git a/src/app/(user)/wallet/_components/wallet-actions.tsx b/src/app/(user)/wallet/_components/wallet-actions.tsx index 257c90e..e19dcdc 100644 --- a/src/app/(user)/wallet/_components/wallet-actions.tsx +++ b/src/app/(user)/wallet/_components/wallet-actions.tsx @@ -9,6 +9,11 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { getErrorMessage } from "@/lib/errors"; +import { WALLET_BALANCE_UPDATED_EVENT } from "./wallet-events"; + +function money(value: number) { + return `¥${value.toFixed(2)}`; +} export function WalletActions() { const router = useRouter(); @@ -59,9 +64,16 @@ export function WalletActions() { const formData = new FormData(form); startRedeem(async () => { try { - await redeemWalletCard(formData); + const result = await redeemWalletCard(formData); form.reset(); - toast.success("充值卡兑换成功"); + if (result.type === "BALANCE") { + window.dispatchEvent(new CustomEvent(WALLET_BALANCE_UPDATED_EVENT, { + detail: { balance: result.balanceAfter }, + })); + toast.success(`余额卡兑换成功,当前余额 ${money(result.balanceAfter)}`); + } else { + toast.success(`${result.planName} 已激活`); + } router.refresh(); } catch (error) { toast.error(getErrorMessage(error, "充值卡兑换失败")); diff --git a/src/app/(user)/wallet/_components/wallet-balance-card.tsx b/src/app/(user)/wallet/_components/wallet-balance-card.tsx new file mode 100644 index 0000000..f9d5e09 --- /dev/null +++ b/src/app/(user)/wallet/_components/wallet-balance-card.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Coins } from "lucide-react"; +import { buttonVariants } from "@/components/ui/button"; +import { WALLET_BALANCE_UPDATED_EVENT, type WalletBalanceUpdatedDetail } from "./wallet-events"; + +function money(value: number) { + return `¥${value.toFixed(2)}`; +} + +export function WalletBalanceCard({ initialBalance }: { initialBalance: number }) { + const [balance, setBalance] = useState(initialBalance); + + useEffect(() => { + function handleBalanceUpdate(event: Event) { + const detail = (event as CustomEvent).detail; + if (typeof detail?.balance === "number" && Number.isFinite(detail.balance)) { + setBalance(detail.balance); + } + } + + window.addEventListener(WALLET_BALANCE_UPDATED_EVENT, handleBalanceUpdate); + return () => window.removeEventListener(WALLET_BALANCE_UPDATED_EVENT, handleBalanceUpdate); + }, []); + + return ( +
+
+
+ + + +
+

当前余额

+

{money(balance)}

+
+
+ + 查看订单 + +
+
+ ); +} diff --git a/src/app/(user)/wallet/_components/wallet-events.ts b/src/app/(user)/wallet/_components/wallet-events.ts new file mode 100644 index 0000000..e86cde7 --- /dev/null +++ b/src/app/(user)/wallet/_components/wallet-events.ts @@ -0,0 +1,5 @@ +export const WALLET_BALANCE_UPDATED_EVENT = "wallet:balance-updated"; + +export type WalletBalanceUpdatedDetail = { + balance: number; +}; diff --git a/src/app/(user)/wallet/_components/wallet-recharge-actions.tsx b/src/app/(user)/wallet/_components/wallet-recharge-actions.tsx new file mode 100644 index 0000000..c015768 --- /dev/null +++ b/src/app/(user)/wallet/_components/wallet-recharge-actions.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { XCircle } from "lucide-react"; +import { cancelWalletRecharge } from "@/actions/user/wallet"; +import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; +import { buttonVariants } from "@/components/ui/button"; + +export function WalletRechargeActions({ rechargeId }: { rechargeId: string }) { + const router = useRouter(); + + return ( +
+ + 继续支付 + + cancelWalletRecharge(rechargeId)} + onSuccess={() => router.refresh()} + > + + 取消支付 + +
+ ); +} diff --git a/src/app/(user)/wallet/page.tsx b/src/app/(user)/wallet/page.tsx index 618d820..bd1ab5d 100644 --- a/src/app/(user)/wallet/page.tsx +++ b/src/app/(user)/wallet/page.tsx @@ -1,14 +1,14 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { Coins, ReceiptText } from "lucide-react"; +import { ReceiptText } from "lucide-react"; import { getActiveSession } from "@/lib/require-auth"; import { formatDate } from "@/lib/utils"; import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell"; import { DataTableShell } from "@/components/shared/data-table-shell"; import { StatusBadge } from "@/components/shared/status-badge"; -import { buttonVariants } from "@/components/ui/button"; import { getPaymentProviderName } from "@/services/payment/catalog"; import { WalletActions } from "./_components/wallet-actions"; +import { WalletBalanceCard } from "./_components/wallet-balance-card"; +import { WalletRechargeActions } from "./_components/wallet-recharge-actions"; import { getWalletPageData } from "./wallet-data"; export const metadata: Metadata = { @@ -47,22 +47,7 @@ export default async function WalletPage() { description="余额可用于订单支付,充值卡可兑换余额或直接激活套餐。" /> -
-
-
- - - -
-

当前余额

-

{money(wallet.balance)}

-
-
- - 查看订单 - -
-
+ @@ -128,9 +113,7 @@ export default async function WalletPage() { {formatDate(order.createdAt)} {order.status === "PENDING" && ( - - 继续支付 - + )} diff --git a/src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx b/src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx index 83fd7eb..d18ce2c 100644 --- a/src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx +++ b/src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx @@ -3,8 +3,10 @@ import { useEffect, useEffectEvent, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { CheckCircle2, Coins, CreditCard } from "lucide-react"; +import { CheckCircle2, Coins, CreditCard, XCircle } from "lucide-react"; +import { cancelWalletRecharge } from "@/actions/user/wallet"; import { AlipayQrView, UsdtView } from "@/app/(payment)/pay/[orderId]/_components/payment-detail-panels"; +import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { Button, buttonVariants } from "@/components/ui/button"; import { fetchJson } from "@/lib/fetch-json"; import { getErrorMessage } from "@/lib/errors"; @@ -53,7 +55,7 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) { const [selectedIdx, setSelectedIdx] = useState(-1); const [recharge, setRecharge] = useState(null); const [payment, setPayment] = useState(null); - const [status, setStatus] = useState<"booting" | "idle" | "creating" | "waiting" | "paid">("booting"); + const [status, setStatus] = useState<"booting" | "idle" | "creating" | "waiting" | "paid" | "cancelled">("booting"); const [pageError, setPageError] = useState(null); const selectedProvider = useMemo( @@ -98,6 +100,11 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) { setStatus("paid"); return; } + if (order.status === "CANCELLED" || order.status === "REFUNDED") { + setPayment(null); + setStatus("cancelled"); + return; + } const defaultIdx = order.paymentMethod ? providerList.findIndex((provider) => provider.provider === order.paymentMethod) @@ -121,6 +128,15 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) { } }); + async function cancelRecharge() { + await cancelWalletRecharge(rechargeId); + setPayment(null); + setRecharge((current) => current ? { ...current, status: "CANCELLED" } : current); + setStatus("cancelled"); + setPageError(null); + router.refresh(); + } + const pollPaymentStatus = useEffectEvent(async () => { if (!payment?.tradeNo) return; try { @@ -168,6 +184,23 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) { ); } + if (status === "cancelled") { + return ( +
+
+ +
+

+ {recharge?.status === "REFUNDED" ? "充值已退款" : "充值已取消"} +

+

这笔充值订单不能继续支付,可返回钱包重新发起充值。

+
+ +
+
+ ); + } + return (
@@ -232,8 +265,22 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) {
)} -
+
返回钱包 + {recharge?.status === "PENDING" && ( + + + 取消支付 + + )}
); diff --git a/src/services/wallet.ts b/src/services/wallet.ts index e32264e..f2fbfeb 100644 --- a/src/services/wallet.ts +++ b/src/services/wallet.ts @@ -1,6 +1,7 @@ import crypto from "crypto"; import { Prisma } from "@prisma/client"; import { prisma, type DbClient } from "@/lib/prisma"; +import { formatDate } from "@/lib/utils"; import { createNotification } from "@/services/notifications"; import { provisionSubscriptionWithDb } from "@/services/provision"; import { getPaymentProviderName } from "@/services/payment/catalog"; @@ -185,6 +186,38 @@ function getPlanCardGenerationLimit(planType: "PROXY" | "STREAMING", availabilit return limits.length > 0 ? Math.min(...limits) : null; } +function getUnavailableRechargeCardMessage(card: { + status: "UNUSED" | "REDEEMED" | "EXPIRED" | "DISABLED"; + redeemedAt: Date | null; + expiresAt: Date | null; +}) { + if (card.status === "REDEEMED") { + return card.redeemedAt + ? `这张充值卡已在 ${formatDate(card.redeemedAt)} 兑换,不能重复使用。` + : "这张充值卡已兑换,不能重复使用。"; + } + if (card.status === "EXPIRED") { + return card.expiresAt + ? `这张充值卡已于 ${formatDate(card.expiresAt)} 过期。` + : "这张充值卡已过期。"; + } + if (card.status === "DISABLED") { + return "这张充值卡已停用,请联系管理员处理。"; + } + return "这张充值卡当前不可兑换。"; +} + +export type RedeemRechargeCardResult = + | { + type: "BALANCE"; + amount: number; + balanceAfter: number; + } + | { + type: "PLAN"; + planName: string; + }; + export async function payOrderWithWallet(orderId: string, userId: string) { const tradeNo = `BAL-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; @@ -379,11 +412,12 @@ export async function createRechargeCards(input: { }); } -export async function redeemRechargeCard(userId: string, rawCode: string) { +export async function redeemRechargeCard(userId: string, rawCode: string): Promise { const code = rawCode.trim().toUpperCase(); if (!code) throw new Error("请输入充值卡卡密"); return prisma.$transaction(async (tx) => { + const redeemedAt = new Date(); const card = await tx.rechargeCard.findUnique({ where: { code }, include: { @@ -398,27 +432,45 @@ export async function redeemRechargeCard(userId: string, rawCode: string) { }); if (!card) throw new Error("充值卡不存在"); - if (card.status !== "UNUSED") throw new Error("这张充值卡已使用或已停用"); - if (card.expiresAt && card.expiresAt < new Date()) { - await tx.rechargeCard.update({ - where: { id: card.id }, + if (card.status !== "UNUSED") throw new Error(getUnavailableRechargeCardMessage(card)); + if (card.expiresAt && card.expiresAt < redeemedAt) { + await tx.rechargeCard.updateMany({ + where: { id: card.id, status: "UNUSED" }, data: { status: "EXPIRED" }, }); - throw new Error("这张充值卡已过期"); + throw new Error(getUnavailableRechargeCardMessage({ ...card, status: "EXPIRED" })); } + const claimed = await tx.rechargeCard.updateMany({ + where: { id: card.id, status: "UNUSED" }, + data: { + status: "REDEEMED", + redeemedById: userId, + redeemedAt, + }, + }); + if (claimed.count === 0) { + throw new Error("这张充值卡刚刚已被兑换,请刷新后查看最新状态。"); + } + + let result: RedeemRechargeCardResult; if (card.type === "BALANCE") { if (!card.balanceAmount || Number(card.balanceAmount) <= 0) { throw new Error("余额充值卡金额无效"); } - await creditWallet(tx, { + const wallet = await creditWallet(tx, { userId, amount: card.balanceAmount, type: "CARD_REDEEM", description: "兑换余额充值卡", rechargeCardId: card.id, }); + result = { + type: "BALANCE", + amount: Number(card.balanceAmount), + balanceAfter: Number(wallet.balance), + }; } else { if (!card.plan) throw new Error("套餐充值卡绑定的套餐不存在"); const { selectedInboundId, trafficGb } = resolvePlanCardConfig({ @@ -445,17 +497,9 @@ export async function redeemRechargeCard(userId: string, rawCode: string) { }); await provisionSubscriptionWithDb(order, tx); + result = { type: "PLAN", planName: card.plan.name }; } - await tx.rechargeCard.update({ - where: { id: card.id }, - data: { - status: "REDEEMED", - redeemedById: userId, - redeemedAt: new Date(), - }, - }); - await createNotification( { userId, @@ -471,7 +515,7 @@ export async function redeemRechargeCard(userId: string, rawCode: string) { tx, ); - return card.type; + return result; }); }