Files
J-Board-Lite/src/app/(admin)/admin/plans/plan-card.tsx
2026-04-30 20:49:03 +10:00

162 lines
5.6 KiB
TypeScript

import { Network, Tv } from "lucide-react";
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
import {
PlanFormValue,
type StreamingServiceOption,
} from "./plan-form";
import { PlanActions } from "./plan-actions";
type NumericLike = number | { toString(): string } | null | undefined;
interface PlanListItem {
id: string;
name: string;
type: "PROXY" | "STREAMING";
description: string | null;
durationDays: number;
sortOrder: number;
isActive: boolean;
price: NumericLike;
nodeId: string | null;
inboundId: string | null;
streamingServiceId: string | null;
pricingMode: "TRAFFIC_SLIDER" | "FIXED_PACKAGE";
fixedTrafficGb: number | null;
fixedPrice: NumericLike;
totalLimit: number | null;
perUserLimit: number | null;
totalTrafficGb: number | null;
allowRenewal: boolean;
allowTrafficTopup: boolean;
renewalPrice: NumericLike;
renewalPricingMode: string;
renewalDurationDays: number | null;
renewalMinDays: number | null;
renewalMaxDays: number | null;
renewalTrafficGb: number | null;
topupPricingMode: string;
topupPricePerGb: NumericLike;
topupFixedPrice: NumericLike;
minTopupGb: number | null;
maxTopupGb: number | null;
pricePerGb: NumericLike;
minTrafficGb: number | null;
maxTrafficGb: number | null;
node: { name: string } | null;
inbound: { protocol: string; port: number; tag: string } | null;
streamingService: { name: string; usedSlots: number; maxSlots: number } | null;
inboundOptions: Array<{
inboundId: string;
inbound: { protocol: string; port: number; tag: string };
}>;
_count: { subscriptions: number };
}
interface PlanCardProps {
plan: PlanListItem;
activeCount: number;
services: StreamingServiceOption[];
batchFormId: string;
}
function toNumber(value: NumericLike): number | null {
return value == null ? null : Number(value);
}
function remainingStockSummary(plan: PlanListItem, activeCount: number) {
if (plan.totalLimit == null) return { value: "∞", hint: "剩余库存", empty: false };
const remaining = Math.max(0, plan.totalLimit - activeCount);
return {
value: remaining.toString(),
hint: remaining === 0 ? "已售罄" : "剩余库存",
empty: remaining === 0,
};
}
function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
return {
id: plan.id,
name: plan.name,
type: plan.type,
description: plan.description,
durationDays: plan.durationDays,
sortOrder: plan.sortOrder,
price: toNumber(plan.price),
nodeId: plan.nodeId,
inboundId: plan.inboundId,
inboundOptionIds: plan.inboundOptions.map((option) => option.inboundId),
streamingServiceId: plan.streamingServiceId,
pricingMode: plan.pricingMode,
fixedTrafficGb: plan.fixedTrafficGb,
fixedPrice: toNumber(plan.fixedPrice),
totalLimit: plan.totalLimit,
perUserLimit: plan.perUserLimit,
totalTrafficGb: plan.totalTrafficGb,
allowRenewal: plan.allowRenewal,
allowTrafficTopup: plan.allowTrafficTopup,
renewalPrice: toNumber(plan.renewalPrice),
renewalPricingMode: plan.renewalPricingMode === "PER_DAY" ? "PER_DAY" : "FIXED_DURATION",
renewalDurationDays: plan.renewalDurationDays,
renewalMinDays: plan.renewalMinDays,
renewalMaxDays: plan.renewalMaxDays,
renewalTrafficGb: plan.renewalTrafficGb,
topupPricingMode: plan.topupPricingMode === "FIXED_AMOUNT" ? "FIXED_AMOUNT" : "PER_GB",
topupPricePerGb: toNumber(plan.topupPricePerGb),
topupFixedPrice: toNumber(plan.topupFixedPrice),
minTopupGb: plan.minTopupGb,
maxTopupGb: plan.maxTopupGb,
pricePerGb: toNumber(plan.pricePerGb),
minTrafficGb: plan.minTrafficGb,
maxTrafficGb: plan.maxTrafficGb,
};
}
export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardProps) {
const planFormValue = buildPlanFormValue(plan);
const stock = remainingStockSummary(plan, activeCount);
const Icon = plan.type === "PROXY" ? Network : Tv;
return (
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
<div className="flex min-w-0 items-start gap-3">
<input
form={batchFormId}
type="checkbox"
name="planIds"
value={plan.id}
aria-label={`选择套餐 ${plan.name}`}
className="mt-2 size-5 rounded-lg border-border accent-primary shadow-sm"
/>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Icon className="size-4" />
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-base font-semibold tracking-tight">{plan.name}</h3>
<StatusBadge tone={plan.type === "PROXY" ? "info" : "warning"}>
{plan.type === "PROXY" ? "代理" : "流媒体"}
</StatusBadge>
<ActiveStatusBadge active={plan.isActive} activeLabel="上架中" inactiveLabel="已下架" />
</div>
</div>
</div>
<div className="inline-flex w-fit items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 lg:justify-self-end">
<span className={`text-lg font-semibold tabular-nums ${stock.empty ? "text-destructive" : "text-foreground"}`}>
{stock.value}
</span>
<span className="text-xs font-medium text-muted-foreground">{stock.hint}</span>
</div>
<div className="flex justify-start lg:justify-end">
<PlanActions
isActive={plan.isActive}
services={services}
plan={planFormValue}
/>
</div>
</section>
);
}