mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: polish wallet recharge cards
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireAdmin } from "@/lib/require-auth";
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
import { createRechargeCards } from "@/services/wallet";
|
import { createRechargeCards } from "@/services/wallet";
|
||||||
@@ -62,3 +63,53 @@ export async function createAdminRechargeCards(formData: FormData) {
|
|||||||
revalidatePath("/admin/plans");
|
revalidatePath("/admin/plans");
|
||||||
revalidatePath("/store");
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireAuth } from "@/lib/require-auth";
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
import { createWalletRechargeOrder, redeemRechargeCard } from "@/services/wallet";
|
import { createWalletRechargeOrder, redeemRechargeCard } from "@/services/wallet";
|
||||||
|
|
||||||
@@ -23,8 +24,40 @@ export async function createWalletRecharge(formData: FormData) {
|
|||||||
export async function redeemWalletCard(formData: FormData) {
|
export async function redeemWalletCard(formData: FormData) {
|
||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
const data = redeemSchema.parse(Object.fromEntries(formData));
|
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("/wallet");
|
||||||
revalidatePath("/subscriptions");
|
revalidatePath("/subscriptions");
|
||||||
revalidatePath("/dashboard");
|
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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
|
||||||
|
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className={cn("mt-1 break-words text-sm font-medium leading-5", mono && "font-mono text-xs")}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap items-center justify-start gap-2 lg:justify-end">
|
||||||
|
<CopyButton text={card.code} />
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
|
||||||
|
<Eye className="size-3.5" />
|
||||||
|
详情
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[36rem]">
|
||||||
|
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<DialogTitle>充值卡详情</DialogTitle>
|
||||||
|
<StatusBadge tone={card.statusTone}>{card.statusLabel}</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<DialogDescription>{card.valueLabel}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody className="flex-1 px-4 py-3">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{details.map((item) => (
|
||||||
|
<DetailItem key={item.label} {...item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{card.transactionLabel && (
|
||||||
|
<div className="mt-3 rounded-lg border border-primary/15 bg-primary/5 px-3 py-2 text-sm text-primary">
|
||||||
|
{card.transactionLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmActionButton
|
||||||
|
title={`删除充值卡 ${card.code}`}
|
||||||
|
description={deleteDescription}
|
||||||
|
confirmLabel="删除卡密"
|
||||||
|
successMessage={card.releasesPlanStock ? "充值卡已删除,套餐库存已释放" : "充值卡已删除"}
|
||||||
|
errorMessage="删除充值卡失败"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteAdminRechargeCard(card.id);
|
||||||
|
}}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
删除
|
||||||
|
</ConfirmActionButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,7 +34,17 @@ export async function getCommerceData(
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
plan: { select: { name: true, type: true } },
|
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,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import type { Metadata } from "next";
|
|||||||
import { Gift, Sparkles, WalletCards } from "lucide-react";
|
import { Gift, Sparkles, WalletCards } from "lucide-react";
|
||||||
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
|
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
|
||||||
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
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 { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||||
import { Pagination } from "@/components/shared/pagination";
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
|
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
|
||||||
|
import type { StatusTone } from "@/components/shared/status-badge";
|
||||||
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -14,6 +14,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import { getCommerceData } from "./commerce-data";
|
import { getCommerceData } from "./commerce-data";
|
||||||
import { CommerceToggleButton } from "./_components/commerce-actions";
|
import { CommerceToggleButton } from "./_components/commerce-actions";
|
||||||
import { DiscountTypeSelect } from "./_components/discount-type-select";
|
import { DiscountTypeSelect } from "./_components/discount-type-select";
|
||||||
|
import { RechargeCardActions, type RechargeCardActionItem } from "./_components/recharge-card-actions";
|
||||||
import { RechargeCardForm } from "./_components/recharge-card-form";
|
import { RechargeCardForm } from "./_components/recharge-card-form";
|
||||||
|
|
||||||
function formatCouponDiscount(type: string, value: unknown) {
|
function formatCouponDiscount(type: string, value: unknown) {
|
||||||
@@ -36,6 +37,76 @@ const rechargeCardStatusLabels: Record<string, string> = {
|
|||||||
DISABLED: "已停用",
|
DISABLED: "已停用",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rechargeCardStatusTones: Record<string, StatusTone> = {
|
||||||
|
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) {
|
function normalizeCommerceTab(value: string | string[] | undefined) {
|
||||||
return value === "manage" || value === "cards" ? value : "create";
|
return value === "manage" || value === "cards" ? value : "create";
|
||||||
}
|
}
|
||||||
@@ -205,7 +276,9 @@ export default async function AdminCommercePage({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SectionHeader title="充值卡列表" />
|
<SectionHeader title="充值卡列表" />
|
||||||
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||||
{rechargeCards.map((card) => (
|
{rechargeCards.map((card) => {
|
||||||
|
const actionCard = formatRechargeCardAction(card);
|
||||||
|
return (
|
||||||
<article key={card.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.6fr)_auto] lg:items-center">
|
<article key={card.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.6fr)_auto] lg:items-center">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
@@ -214,27 +287,24 @@ export default async function AdminCommercePage({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||||
<h3 className="min-w-0 truncate font-mono font-semibold">{card.code}</h3>
|
<h3 className="min-w-0 truncate font-mono font-semibold">{card.code}</h3>
|
||||||
<StatusBadge tone={card.status === "UNUSED" ? "success" : "neutral"}>
|
<StatusBadge tone={actionCard.statusTone}>
|
||||||
{rechargeCardStatusLabels[card.status] ?? "未知状态"}
|
{actionCard.statusLabel}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{card.type === "BALANCE"
|
{actionCard.typeLabel} · {actionCard.valueLabel}
|
||||||
? `余额卡 ¥${Number(card.balanceAmount ?? 0).toFixed(2)}`
|
|
||||||
: `套餐卡 ${card.plan?.name ?? "套餐已删除"}`}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{card.batchName && <StatusBadge>{card.batchName}</StatusBadge>}
|
{card.batchName && <StatusBadge>{card.batchName}</StatusBadge>}
|
||||||
{card.redeemedBy && <StatusBadge tone="info">{card.redeemedBy.email}</StatusBadge>}
|
{card.redeemedBy && <StatusBadge tone="info">{userLabel(card.redeemedBy, "已兑换")}</StatusBadge>}
|
||||||
<StatusBadge>{card.expiresAt ? `到期 ${formatDate(card.expiresAt)}` : "永不过期"}</StatusBadge>
|
<StatusBadge>{card.expiresAt ? `到期 ${formatDate(card.expiresAt)}` : "永不过期"}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-start lg:justify-end">
|
<RechargeCardActions card={actionCard} />
|
||||||
<CopyButton text={card.code} />
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{rechargeCards.length === 0 && (
|
{rechargeCards.length === 0 && (
|
||||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">暂无充值卡</p>
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">暂无充值卡</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<DataTableShell
|
||||||
|
isEmpty={rechargeOrders.length === 0}
|
||||||
|
emptyTitle="暂无充值订单"
|
||||||
|
emptyDescription="用户发起钱包充值后,会在这里显示支付与入账状态。"
|
||||||
|
mobileCards={rechargeOrders.map((order) => (
|
||||||
|
<article key={order.id} className="space-y-3 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="break-all text-sm font-semibold">{order.user.email}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{order.user.name || "未设置昵称"}</p>
|
||||||
|
</div>
|
||||||
|
<OrderStatusBadge status={order.status} />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/25 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs text-muted-foreground">充值金额</p>
|
||||||
|
<p className="font-semibold tabular-nums">{formatAmount(order.amount)}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{getPaymentLabel(order.paymentMethod)} · {order.tradeNo || "无交易号"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{formatDateShort(order.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
>
|
||||||
|
<DataTable aria-label="充值订单列表" className="min-w-[980px]">
|
||||||
|
<DataTableHead>
|
||||||
|
<DataTableHeaderRow>
|
||||||
|
<DataTableHeadCell>用户</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>金额</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>支付</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>状态</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>备注</DataTableHeadCell>
|
||||||
|
<DataTableHeadCell>时间</DataTableHeadCell>
|
||||||
|
</DataTableHeaderRow>
|
||||||
|
</DataTableHead>
|
||||||
|
<DataTableBody>
|
||||||
|
{rechargeOrders.map((order) => (
|
||||||
|
<DataTableRow key={order.id}>
|
||||||
|
<DataTableCell className="max-w-56 whitespace-normal break-all">
|
||||||
|
<p className="font-medium">{order.user.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{order.user.name || "未设置昵称"}</p>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="font-semibold tabular-nums">{formatAmount(order.amount)}</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>{getPaymentLabel(order.paymentMethod)}</p>
|
||||||
|
<p className="max-w-56 break-all text-xs text-muted-foreground">
|
||||||
|
{order.tradeNo || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<OrderStatusBadge status={order.status} />
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="max-w-64 whitespace-normal break-words text-xs text-muted-foreground">
|
||||||
|
{order.note || order.paymentRef || "—"}
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell className="whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateShort(order.createdAt)}
|
||||||
|
</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
))}
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</DataTableShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,14 @@ export type AdminOrderRow = Prisma.OrderGetPayload<{
|
|||||||
include: typeof adminOrderInclude;
|
include: typeof adminOrderInclude;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
const adminRechargeOrderInclude = {
|
||||||
|
user: true,
|
||||||
|
} satisfies Prisma.WalletRechargeOrderInclude;
|
||||||
|
|
||||||
|
export type AdminRechargeOrderRow = Prisma.WalletRechargeOrderGetPayload<{
|
||||||
|
include: typeof adminRechargeOrderInclude;
|
||||||
|
}>;
|
||||||
|
|
||||||
export async function getAdminOrders(
|
export async function getAdminOrders(
|
||||||
searchParams: Record<string, string | string[] | undefined>,
|
searchParams: Record<string, string | string[] | undefined>,
|
||||||
) {
|
) {
|
||||||
@@ -52,3 +60,38 @@ export async function getAdminOrders(
|
|||||||
|
|
||||||
return { orders, total, page, pageSize, filters: { q, status, kind, reviewStatus } };
|
return { orders, total, page, pageSize, filters: { q, status, kind, reviewStatus } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAdminRechargeOrders(
|
||||||
|
searchParams: Record<string, string | string[] | undefined>,
|
||||||
|
) {
|
||||||
|
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 } };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,56 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
import { AdminFilterBar } from "@/components/admin/filter-bar";
|
||||||
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell } from "@/components/shared/page-shell";
|
||||||
import { Pagination } from "@/components/shared/pagination";
|
import { Pagination } from "@/components/shared/pagination";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { OrdersTable } from "./_components/orders-table";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "订单管理",
|
title: "订单管理",
|
||||||
description: "跟踪订单状态、审查结果与支付记录。",
|
description: "跟踪订单状态、审查结果与支付记录。",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeOrdersTab(value: string | string[] | undefined) {
|
||||||
|
return value === "recharge" ? "recharge" : "orders";
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrdersTabLink({
|
||||||
|
href,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
active: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: active ? "default" : "outline", size: "sm" }),
|
||||||
|
"min-w-28",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function OrdersPage({
|
export default async function OrdersPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
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 (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -23,24 +58,34 @@ export default async function OrdersPage({
|
|||||||
eyebrow="商品与订单"
|
eyebrow="商品与订单"
|
||||||
title="订单管理"
|
title="订单管理"
|
||||||
/>
|
/>
|
||||||
|
<div className="surface-card flex w-fit flex-wrap gap-2 rounded-xl p-1">
|
||||||
|
<OrdersTabLink href="/admin/orders?tab=orders" active={activeTab === "orders"}>
|
||||||
|
商品订单
|
||||||
|
</OrdersTabLink>
|
||||||
|
<OrdersTabLink href="/admin/orders?tab=recharge" active={activeTab === "recharge"}>
|
||||||
|
充值订单
|
||||||
|
</OrdersTabLink>
|
||||||
|
</div>
|
||||||
<AdminFilterBar
|
<AdminFilterBar
|
||||||
q={filters.q}
|
q={filters.q}
|
||||||
searchPlaceholder="搜索邮箱、套餐、交易号"
|
searchPlaceholder={activeTab === "orders" ? "搜索邮箱、套餐、交易号" : "搜索邮箱、交易号"}
|
||||||
selects={[
|
selects={[
|
||||||
{
|
{
|
||||||
name: "status",
|
name: "status",
|
||||||
value: filters.status,
|
value: filters.status,
|
||||||
options: [
|
options: [
|
||||||
{ label: "全部状态", value: "" },
|
{ label: "全部状态", value: "" },
|
||||||
{ label: "待确认", value: "PENDING" },
|
{ label: activeTab === "orders" ? "待确认" : "待支付", value: "PENDING" },
|
||||||
{ label: "已支付", value: "PAID" },
|
{ label: activeTab === "orders" ? "已支付" : "已入账", value: "PAID" },
|
||||||
{ label: "已取消", value: "CANCELLED" },
|
{ label: "已取消", value: "CANCELLED" },
|
||||||
{ label: "已退款", value: "REFUNDED" },
|
{ label: "已退款", value: "REFUNDED" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
...(activeTab === "orders"
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: "kind",
|
name: "kind",
|
||||||
value: filters.kind,
|
value: orderData!.filters.kind,
|
||||||
options: [
|
options: [
|
||||||
{ label: "全部类型", value: "" },
|
{ label: "全部类型", value: "" },
|
||||||
{ label: "新购", value: "NEW_PURCHASE" },
|
{ label: "新购", value: "NEW_PURCHASE" },
|
||||||
@@ -50,7 +95,7 @@ export default async function OrdersPage({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "reviewStatus",
|
name: "reviewStatus",
|
||||||
value: filters.reviewStatus,
|
value: orderData!.filters.reviewStatus,
|
||||||
options: [
|
options: [
|
||||||
{ label: "全部审查", value: "" },
|
{ label: "全部审查", value: "" },
|
||||||
{ label: "正常", value: "NORMAL" },
|
{ label: "正常", value: "NORMAL" },
|
||||||
@@ -58,10 +103,33 @@ export default async function OrdersPage({
|
|||||||
{ label: "已解决", value: "RESOLVED" },
|
{ label: "已解决", value: "RESOLVED" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="tab" value={activeTab} />
|
||||||
|
</AdminFilterBar>
|
||||||
|
{activeTab === "orders" ? (
|
||||||
|
<>
|
||||||
|
<OrdersTable orders={orderData!.orders} />
|
||||||
|
<Pagination
|
||||||
|
total={orderData!.total}
|
||||||
|
pageSize={orderData!.pageSize}
|
||||||
|
page={orderData!.page}
|
||||||
|
fixedParams={{ tab: "orders" }}
|
||||||
/>
|
/>
|
||||||
<OrdersTable orders={orders} />
|
</>
|
||||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
) : (
|
||||||
|
<>
|
||||||
|
<RechargeOrdersTable rechargeOrders={rechargeData!.rechargeOrders} />
|
||||||
|
<Pagination
|
||||||
|
total={rechargeData!.total}
|
||||||
|
pageSize={rechargeData!.pageSize}
|
||||||
|
page={rechargeData!.page}
|
||||||
|
fixedParams={{ tab: "recharge" }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getErrorMessage } from "@/lib/errors";
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { WALLET_BALANCE_UPDATED_EVENT } from "./wallet-events";
|
||||||
|
|
||||||
|
function money(value: number) {
|
||||||
|
return `¥${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function WalletActions() {
|
export function WalletActions() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -59,9 +64,16 @@ export function WalletActions() {
|
|||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
startRedeem(async () => {
|
startRedeem(async () => {
|
||||||
try {
|
try {
|
||||||
await redeemWalletCard(formData);
|
const result = await redeemWalletCard(formData);
|
||||||
form.reset();
|
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();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error, "充值卡兑换失败"));
|
toast.error(getErrorMessage(error, "充值卡兑换失败"));
|
||||||
|
|||||||
46
src/app/(user)/wallet/_components/wallet-balance-card.tsx
Normal file
46
src/app/(user)/wallet/_components/wallet-balance-card.tsx
Normal file
@@ -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<WalletBalanceUpdatedDetail>).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 (
|
||||||
|
<section className="surface-card rounded-xl p-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
<Coins className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">当前余额</p>
|
||||||
|
<p className="mt-1 text-3xl font-semibold tabular-nums">{money(balance)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/orders" className={buttonVariants({ variant: "outline" })}>
|
||||||
|
查看订单
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/(user)/wallet/_components/wallet-events.ts
Normal file
5
src/app/(user)/wallet/_components/wallet-events.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const WALLET_BALANCE_UPDATED_EVENT = "wallet:balance-updated";
|
||||||
|
|
||||||
|
export type WalletBalanceUpdatedDetail = {
|
||||||
|
balance: number;
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Link href={`/wallet/recharge/${rechargeId}`} className={buttonVariants({ size: "sm" })}>
|
||||||
|
继续支付
|
||||||
|
</Link>
|
||||||
|
<ConfirmActionButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
title="取消这笔充值?"
|
||||||
|
description="取消后这笔充值订单不能继续支付。"
|
||||||
|
confirmLabel="取消充值"
|
||||||
|
successMessage="充值订单已取消"
|
||||||
|
errorMessage="取消充值订单失败"
|
||||||
|
onConfirm={() => cancelWalletRecharge(rechargeId)}
|
||||||
|
onSuccess={() => router.refresh()}
|
||||||
|
>
|
||||||
|
<XCircle className="size-3.5" />
|
||||||
|
取消支付
|
||||||
|
</ConfirmActionButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import { ReceiptText } from "lucide-react";
|
||||||
import { Coins, ReceiptText } from "lucide-react";
|
|
||||||
import { getActiveSession } from "@/lib/require-auth";
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||||
import { DataTableShell } from "@/components/shared/data-table-shell";
|
import { DataTableShell } from "@/components/shared/data-table-shell";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import { getPaymentProviderName } from "@/services/payment/catalog";
|
import { getPaymentProviderName } from "@/services/payment/catalog";
|
||||||
import { WalletActions } from "./_components/wallet-actions";
|
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";
|
import { getWalletPageData } from "./wallet-data";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -47,22 +47,7 @@ export default async function WalletPage() {
|
|||||||
description="余额可用于订单支付,充值卡可兑换余额或直接激活套餐。"
|
description="余额可用于订单支付,充值卡可兑换余额或直接激活套餐。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="surface-card rounded-xl p-5">
|
<WalletBalanceCard key={Number(wallet.balance)} initialBalance={Number(wallet.balance)} />
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
||||||
<Coins className="size-5" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">当前余额</p>
|
|
||||||
<p className="mt-1 text-3xl font-semibold tabular-nums">{money(wallet.balance)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link href="/orders" className={buttonVariants({ variant: "outline" })}>
|
|
||||||
查看订单
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<WalletActions />
|
<WalletActions />
|
||||||
|
|
||||||
@@ -128,9 +113,7 @@ export default async function WalletPage() {
|
|||||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{formatDate(order.createdAt)}</td>
|
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{formatDate(order.createdAt)}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{order.status === "PENDING" && (
|
{order.status === "PENDING" && (
|
||||||
<Link href={`/wallet/recharge/${order.id}`} className={buttonVariants({ size: "sm" })}>
|
<WalletRechargeActions rechargeId={order.id} />
|
||||||
继续支付
|
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import { useEffect, useEffectEvent, useMemo, useState } from "react";
|
import { useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
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 { 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 { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { fetchJson } from "@/lib/fetch-json";
|
import { fetchJson } from "@/lib/fetch-json";
|
||||||
import { getErrorMessage } from "@/lib/errors";
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
@@ -53,7 +55,7 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) {
|
|||||||
const [selectedIdx, setSelectedIdx] = useState(-1);
|
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||||||
const [recharge, setRecharge] = useState<RechargeSnapshot | null>(null);
|
const [recharge, setRecharge] = useState<RechargeSnapshot | null>(null);
|
||||||
const [payment, setPayment] = useState<PaymentInfo | null>(null);
|
const [payment, setPayment] = useState<PaymentInfo | null>(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<string | null>(null);
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedProvider = useMemo(
|
const selectedProvider = useMemo(
|
||||||
@@ -98,6 +100,11 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) {
|
|||||||
setStatus("paid");
|
setStatus("paid");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (order.status === "CANCELLED" || order.status === "REFUNDED") {
|
||||||
|
setPayment(null);
|
||||||
|
setStatus("cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultIdx = order.paymentMethod
|
const defaultIdx = order.paymentMethod
|
||||||
? providerList.findIndex((provider) => provider.provider === 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 () => {
|
const pollPaymentStatus = useEffectEvent(async () => {
|
||||||
if (!payment?.tradeNo) return;
|
if (!payment?.tradeNo) return;
|
||||||
try {
|
try {
|
||||||
@@ -168,6 +184,23 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return (
|
||||||
|
<div className="surface-card mx-auto max-w-xl rounded-xl p-6 text-center">
|
||||||
|
<div className="mx-auto flex size-12 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||||
|
<XCircle className="size-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-2xl font-semibold">
|
||||||
|
{recharge?.status === "REFUNDED" ? "充值已退款" : "充值已取消"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">这笔充值订单不能继续支付,可返回钱包重新发起充值。</p>
|
||||||
|
<div className="mt-5 flex justify-center">
|
||||||
|
<Button onClick={() => router.push("/wallet")}>返回钱包</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="surface-card mx-auto max-w-xl space-y-5 rounded-xl p-5">
|
<div className="surface-card mx-auto max-w-xl space-y-5 rounded-xl p-5">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -232,8 +265,22 @@ export function RechargePayClient({ rechargeId }: { rechargeId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<Link href="/wallet" className={buttonVariants({ variant: "outline" })}>返回钱包</Link>
|
<Link href="/wallet" className={buttonVariants({ variant: "outline" })}>返回钱包</Link>
|
||||||
|
{recharge?.status === "PENDING" && (
|
||||||
|
<ConfirmActionButton
|
||||||
|
variant="destructive"
|
||||||
|
title="取消这笔充值?"
|
||||||
|
description="取消后这笔充值订单不能继续支付。"
|
||||||
|
confirmLabel="取消充值"
|
||||||
|
successMessage="充值订单已取消"
|
||||||
|
errorMessage="取消充值订单失败"
|
||||||
|
onConfirm={cancelRecharge}
|
||||||
|
>
|
||||||
|
<XCircle className="size-4" />
|
||||||
|
取消支付
|
||||||
|
</ConfirmActionButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma, type DbClient } from "@/lib/prisma";
|
import { prisma, type DbClient } from "@/lib/prisma";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
import { createNotification } from "@/services/notifications";
|
import { createNotification } from "@/services/notifications";
|
||||||
import { provisionSubscriptionWithDb } from "@/services/provision";
|
import { provisionSubscriptionWithDb } from "@/services/provision";
|
||||||
import { getPaymentProviderName } from "@/services/payment/catalog";
|
import { getPaymentProviderName } from "@/services/payment/catalog";
|
||||||
@@ -185,6 +186,38 @@ function getPlanCardGenerationLimit(planType: "PROXY" | "STREAMING", availabilit
|
|||||||
return limits.length > 0 ? Math.min(...limits) : null;
|
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) {
|
export async function payOrderWithWallet(orderId: string, userId: string) {
|
||||||
const tradeNo = `BAL-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
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<RedeemRechargeCardResult> {
|
||||||
const code = rawCode.trim().toUpperCase();
|
const code = rawCode.trim().toUpperCase();
|
||||||
if (!code) throw new Error("请输入充值卡卡密");
|
if (!code) throw new Error("请输入充值卡卡密");
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const redeemedAt = new Date();
|
||||||
const card = await tx.rechargeCard.findUnique({
|
const card = await tx.rechargeCard.findUnique({
|
||||||
where: { code },
|
where: { code },
|
||||||
include: {
|
include: {
|
||||||
@@ -398,27 +432,45 @@ export async function redeemRechargeCard(userId: string, rawCode: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!card) throw new Error("充值卡不存在");
|
if (!card) throw new Error("充值卡不存在");
|
||||||
if (card.status !== "UNUSED") throw new Error("这张充值卡已使用或已停用");
|
if (card.status !== "UNUSED") throw new Error(getUnavailableRechargeCardMessage(card));
|
||||||
if (card.expiresAt && card.expiresAt < new Date()) {
|
if (card.expiresAt && card.expiresAt < redeemedAt) {
|
||||||
await tx.rechargeCard.update({
|
await tx.rechargeCard.updateMany({
|
||||||
where: { id: card.id },
|
where: { id: card.id, status: "UNUSED" },
|
||||||
data: { status: "EXPIRED" },
|
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.type === "BALANCE") {
|
||||||
if (!card.balanceAmount || Number(card.balanceAmount) <= 0) {
|
if (!card.balanceAmount || Number(card.balanceAmount) <= 0) {
|
||||||
throw new Error("余额充值卡金额无效");
|
throw new Error("余额充值卡金额无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
await creditWallet(tx, {
|
const wallet = await creditWallet(tx, {
|
||||||
userId,
|
userId,
|
||||||
amount: card.balanceAmount,
|
amount: card.balanceAmount,
|
||||||
type: "CARD_REDEEM",
|
type: "CARD_REDEEM",
|
||||||
description: "兑换余额充值卡",
|
description: "兑换余额充值卡",
|
||||||
rechargeCardId: card.id,
|
rechargeCardId: card.id,
|
||||||
});
|
});
|
||||||
|
result = {
|
||||||
|
type: "BALANCE",
|
||||||
|
amount: Number(card.balanceAmount),
|
||||||
|
balanceAfter: Number(wallet.balance),
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
if (!card.plan) throw new Error("套餐充值卡绑定的套餐不存在");
|
if (!card.plan) throw new Error("套餐充值卡绑定的套餐不存在");
|
||||||
const { selectedInboundId, trafficGb } = resolvePlanCardConfig({
|
const { selectedInboundId, trafficGb } = resolvePlanCardConfig({
|
||||||
@@ -445,17 +497,9 @@ export async function redeemRechargeCard(userId: string, rawCode: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await provisionSubscriptionWithDb(order, tx);
|
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(
|
await createNotification(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
@@ -471,7 +515,7 @@ export async function redeemRechargeCard(userId: string, rawCode: string) {
|
|||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|
||||||
return card.type;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user