mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
325 lines
15 KiB
TypeScript
325 lines
15 KiB
TypeScript
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 { 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";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
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) {
|
|
const numericValue = Number(value);
|
|
if (type === "PERCENT_OFF") {
|
|
return `折扣 ${numericValue}%`;
|
|
}
|
|
return `立减 ¥${numericValue.toFixed(2)}`;
|
|
}
|
|
|
|
export const metadata: Metadata = {
|
|
title: "商业配置",
|
|
description: "管理优惠券与满减规则。",
|
|
};
|
|
|
|
const rechargeCardStatusLabels: Record<string, string> = {
|
|
UNUSED: "未使用",
|
|
REDEEMED: "已兑换",
|
|
EXPIRED: "已过期",
|
|
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) {
|
|
return value === "manage" || value === "cards" ? value : "create";
|
|
}
|
|
|
|
export default async function AdminCommercePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
}) {
|
|
const params = await searchParams;
|
|
const activeTab = normalizeCommerceTab(params.tab);
|
|
const { coupons, promotions, rechargeCards, rechargeCardPagination, plans } = await getCommerceData(params);
|
|
|
|
return (
|
|
<PageShell>
|
|
<PageHeader
|
|
eyebrow="商业配置"
|
|
title="优惠与奖励"
|
|
/>
|
|
|
|
<Tabs defaultValue={activeTab} className="space-y-6">
|
|
<TabsList variant="line" className="surface-card p-1">
|
|
<TabsTrigger value="create">新建规则</TabsTrigger>
|
|
<TabsTrigger value="manage">规则列表</TabsTrigger>
|
|
<TabsTrigger value="cards">充值卡</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="create">
|
|
<section className="grid gap-5 xl:grid-cols-2">
|
|
<form action={createCoupon} className="form-panel space-y-4">
|
|
<SectionHeader title="新建优惠券" />
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="coupon-code">优惠码</Label>
|
|
<Input id="coupon-code" name="code" placeholder="WELCOME10" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="coupon-name">名称</Label>
|
|
<Input id="coupon-name" name="name" placeholder="新人礼遇" required />
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="coupon-type">优惠类型</Label>
|
|
<DiscountTypeSelect />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="coupon-value">优惠值</Label>
|
|
<Input id="coupon-value" name="discountValue" type="number" step="0.01" min="0.01" placeholder="10 或 15" required />
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<Input name="thresholdAmount" type="number" step="0.01" placeholder="满多少可用" />
|
|
<Input name="totalLimit" type="number" placeholder="总次数" />
|
|
<Input name="perUserLimit" type="number" placeholder="每人次数" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="coupon-public">用户可见</Label>
|
|
<BooleanToggle
|
|
id="coupon-public"
|
|
name="isPublic"
|
|
defaultValue
|
|
trueLabel="公开展示"
|
|
falseLabel="仅发放"
|
|
ariaLabel="用户可见"
|
|
/>
|
|
</div>
|
|
<PendingSubmitButton className="w-full" pendingLabel="创建中...">创建优惠券</PendingSubmitButton>
|
|
</form>
|
|
|
|
<form action={createPromotionRule} className="form-panel space-y-4">
|
|
<SectionHeader title="新建满减" />
|
|
<div className="space-y-2">
|
|
<Label htmlFor="promotion-name">规则名称</Label>
|
|
<Input id="promotion-name" name="name" placeholder="满百礼遇" required />
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="promotion-threshold">门槛金额</Label>
|
|
<Input id="promotion-threshold" name="thresholdAmount" type="number" step="0.01" min="0.01" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="promotion-discount">减免金额</Label>
|
|
<Input id="promotion-discount" name="discountAmount" type="number" step="0.01" min="0.01" required />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="promotion-sort">排序</Label>
|
|
<Input id="promotion-sort" name="sortOrder" type="number" defaultValue={100} />
|
|
</div>
|
|
<PendingSubmitButton className="w-full" pendingLabel="创建中...">创建满减</PendingSubmitButton>
|
|
</form>
|
|
</section>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="manage" className="space-y-6">
|
|
<section className="space-y-4">
|
|
<SectionHeader title="优惠券" />
|
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
|
{coupons.map((coupon) => (
|
|
<article key={coupon.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.9fr)_auto] lg:items-center">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300"><Gift className="size-4" /></span>
|
|
<div className="min-w-0">
|
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
|
<h3 className="min-w-0 truncate font-semibold leading-6">{coupon.name}</h3>
|
|
<ActiveStatusBadge active={coupon.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
|
</div>
|
|
<p className="mt-1 truncate font-mono text-sm text-primary">{coupon.code}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<StatusBadge tone="warning">
|
|
{formatCouponDiscount(coupon.discountType, coupon.discountValue)}
|
|
</StatusBadge>
|
|
<StatusBadge>{coupon.thresholdAmount == null ? "无门槛" : `满 ¥${Number(coupon.thresholdAmount).toFixed(2)}`}</StatusBadge>
|
|
<StatusBadge>{coupon.isPublic ? "公开展示" : "仅发放"}</StatusBadge>
|
|
<StatusBadge>订单 {coupon._count.orders} · 发放 {coupon._count.grants}</StatusBadge>
|
|
</div>
|
|
<div className="flex justify-start lg:justify-end">
|
|
<CommerceToggleButton kind="coupon" id={coupon.id} active={coupon.isActive} />
|
|
</div>
|
|
</article>
|
|
))}
|
|
{coupons.length === 0 && (
|
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">暂无优惠券</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-4">
|
|
<SectionHeader title="满减规则" />
|
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
|
{promotions.map((rule) => (
|
|
<article key={rule.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.75fr)_auto] lg:items-center">
|
|
<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"><Sparkles className="size-4" /></span>
|
|
<div className="min-w-0">
|
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
|
<h3 className="min-w-0 truncate font-semibold leading-6">{rule.name}</h3>
|
|
<ActiveStatusBadge active={rule.isActive} activeLabel="启用中" inactiveLabel="已停用" />
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">满 ¥{Number(rule.thresholdAmount).toFixed(2)} 减 ¥{Number(rule.discountAmount).toFixed(2)}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<StatusBadge tone="info">减 ¥{Number(rule.discountAmount).toFixed(2)}</StatusBadge>
|
|
<StatusBadge>门槛 ¥{Number(rule.thresholdAmount).toFixed(2)}</StatusBadge>
|
|
<StatusBadge>排序 {rule.sortOrder}</StatusBadge>
|
|
</div>
|
|
<div className="flex justify-start lg:justify-end">
|
|
<CommerceToggleButton kind="promotion" id={rule.id} active={rule.isActive} />
|
|
</div>
|
|
</article>
|
|
))}
|
|
{promotions.length === 0 && (
|
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">暂无满减规则</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="cards" className="space-y-6">
|
|
<section className="grid gap-5 xl:grid-cols-[minmax(22rem,0.75fr)_1fr]">
|
|
<RechargeCardForm plans={plans} />
|
|
|
|
<div className="space-y-4">
|
|
<SectionHeader title="充值卡列表" />
|
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
|
{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">
|
|
<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">
|
|
<WalletCards className="size-4" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<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>
|
|
<StatusBadge tone={actionCard.statusTone}>
|
|
{actionCard.statusLabel}
|
|
</StatusBadge>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{actionCard.typeLabel} · {actionCard.valueLabel}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{card.batchName && <StatusBadge>{card.batchName}</StatusBadge>}
|
|
{card.redeemedBy && <StatusBadge tone="info">{userLabel(card.redeemedBy, "已兑换")}</StatusBadge>}
|
|
<StatusBadge>{card.expiresAt ? `到期 ${formatDate(card.expiresAt)}` : "永不过期"}</StatusBadge>
|
|
</div>
|
|
<RechargeCardActions card={actionCard} />
|
|
</article>
|
|
);
|
|
})}
|
|
{rechargeCards.length === 0 && (
|
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">暂无充值卡</p>
|
|
)}
|
|
</div>
|
|
<Pagination
|
|
total={rechargeCardPagination.total}
|
|
pageSize={rechargeCardPagination.pageSize}
|
|
page={rechargeCardPagination.page}
|
|
fixedParams={{ tab: "cards" }}
|
|
/>
|
|
</div>
|
|
</section>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</PageShell>
|
|
);
|
|
}
|