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 (
+
+ );
+}
+
+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 (
+
+
+
+
{
+ 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;
});
}