mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
162 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|