feat: add wallet and recharge cards

This commit is contained in:
JetSprow
2026-05-01 02:31:29 +10:00
parent 6d6489817d
commit 018bed3f36
32 changed files with 2058 additions and 170 deletions

View File

@@ -0,0 +1,124 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { createAdminRechargeCards } from "@/actions/admin/recharge-cards";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getErrorMessage } from "@/lib/errors";
interface PlanOption {
id: string;
name: string;
type: "PROXY" | "STREAMING";
remainingCount: number | null;
}
export function RechargeCardForm({ plans }: { plans: PlanOption[] }) {
const router = useRouter();
const [type, setType] = useState<"BALANCE" | "PLAN">("BALANCE");
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
const [pending, startTransition] = useTransition();
const selectedPlan = plans.find((plan) => plan.id === planId) ?? null;
const planSoldOut = type === "PLAN" && selectedPlan?.remainingCount === 0;
return (
<form
className="form-panel space-y-4"
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
formData.set("type", type);
if (type === "PLAN") formData.set("planId", planId);
startTransition(async () => {
try {
await createAdminRechargeCards(formData);
toast.success("充值卡已生成");
form.reset();
router.refresh();
} catch (error) {
toast.error(getErrorMessage(error, "生成充值卡失败"));
}
});
}}
>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<BooleanToggle
value={type === "PLAN"}
onChange={(value) => setType(value ? "PLAN" : "BALANCE")}
trueLabel="套餐卡"
falseLabel="余额卡"
ariaLabel="充值卡类型"
className="w-36"
/>
</div>
<input type="hidden" name="type" value={type} />
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="recharge-card-quantity"></Label>
<Input
id="recharge-card-quantity"
name="quantity"
type="number"
min="1"
max={type === "PLAN" && selectedPlan?.remainingCount != null ? Math.min(200, selectedPlan.remainingCount) : 200}
defaultValue={1}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="recharge-card-batch"></Label>
<Input id="recharge-card-batch" name="batchName" placeholder="五月活动" />
</div>
</div>
{type === "BALANCE" ? (
<div className="space-y-2">
<Label htmlFor="recharge-card-amount"></Label>
<Input id="recharge-card-amount" name="balanceAmount" type="number" min="0.01" step="0.01" placeholder="100.00" required />
</div>
) : (
<div className="space-y-2">
<Label></Label>
<Select value={planId} onValueChange={(value) => setPlanId(value ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择套餐" />
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.name} · {plan.type === "PROXY" ? "代理" : "流媒体"} · {plan.remainingCount ?? "不限"}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPlan && (
<p className="text-xs text-muted-foreground">
{selectedPlan.remainingCount == null ? "库存不限" : `当前可生成 ${selectedPlan.remainingCount}`}
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="recharge-card-expires"></Label>
<Input id="recharge-card-expires" name="expiresAt" type="datetime-local" />
</div>
<Button className="w-full" disabled={pending || (type === "PLAN" && (!planId || planSoldOut))}>
{pending ? "生成中..." : planSoldOut ? "套餐已售罄" : "生成充值卡"}
</Button>
</form>
);
}