Files
J-Board-Lite/src/app/(admin)/admin/subscriptions/[id]/_components/subscription-access-risk-section.tsx
2026-04-29 16:12:51 +10:00

234 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import type {
SubscriptionAccessLog,
SubscriptionRiskEvent,
SubscriptionStatus,
SubscriptionType,
UserStatus,
} from "@prisma/client";
import { AlertTriangle, ShieldCheck, UserRound } from "lucide-react";
import { DataTableShell } from "@/components/admin/data-table-shell";
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeadCell,
DataTableHeaderRow,
DataTableRow,
} from "@/components/shared/data-table";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
UserStatusBadge,
} from "@/components/shared/domain-badges";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { buttonVariants } from "@/components/ui/button";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { formatDate, formatDateShort } from "@/lib/utils";
interface RiskOwner {
id: string;
email: string;
name: string | null;
status: UserStatus;
}
interface RiskSubscription {
id: string;
status: SubscriptionStatus;
endDate: Date;
plan: {
name: string;
type: SubscriptionType;
};
}
function formatLocation(item: Pick<SubscriptionAccessLog, "country" | "region" | "regionCode" | "city">) {
const parts = [item.country, item.region || item.regionCode, item.city].filter(Boolean);
return parts.length > 0 ? parts.join(" / ") : "未知";
}
function kindLabel(kind: SubscriptionAccessLog["kind"] | SubscriptionRiskEvent["kind"]) {
return kind === "AGGREGATE" ? "总订阅" : "单订阅";
}
function reasonLabel(reason: SubscriptionRiskEvent["reason"]) {
switch (reason) {
case "CITY_VARIANCE_WARNING":
return "城市异常警告";
case "CITY_VARIANCE_SUSPEND":
return "城市异常暂停";
case "REGION_VARIANCE_WARNING":
return "省/地区异常警告";
case "REGION_VARIANCE_SUSPEND":
return "省/地区异常暂停";
case "COUNTRY_VARIANCE_WARNING":
return "国家异常警告";
case "COUNTRY_VARIANCE_SUSPEND":
return "国家异常暂停";
}
}
function reviewStatusLabel(status: SubscriptionRiskEvent["reviewStatus"]) {
switch (status) {
case "OPEN":
return "待处理";
case "ACKNOWLEDGED":
return "已确认";
case "RESOLVED":
return "已解决";
}
}
function reviewStatusTone(status: SubscriptionRiskEvent["reviewStatus"]): StatusTone {
if (status === "RESOLVED") return "success";
if (status === "ACKNOWLEDGED") return "info";
return "warning";
}
function canRestoreFromEvent(event: SubscriptionRiskEvent, subscription: RiskSubscription) {
return event.subscriptionId === subscription.id
&& subscription.status === "SUSPENDED"
&& subscription.endDate > new Date();
}
export function SubscriptionAccessRiskSection({
accessLogs,
riskEvents,
owner,
subscription,
}: {
accessLogs: SubscriptionAccessLog[];
riskEvents: SubscriptionRiskEvent[];
owner: RiskOwner;
subscription: RiskSubscription;
}) {
return (
<section className="surface-card space-y-5 rounded-xl p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-5" />
</span>
<div>
<h3 className="text-lg font-semibold tracking-[-0.02em]">访</h3>
<p className="mt-0.5 text-sm text-muted-foreground"> IP</p>
</div>
</div>
<Link href="/admin/subscription-risk" className={buttonVariants({ variant: "outline", size: "sm" })}>
</Link>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="rounded-lg border border-border/70 bg-muted/25 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<UserRound className="size-4 text-primary" />
</div>
<div className="space-y-1 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Link href={"/admin/users/" + owner.id} className="break-all font-medium hover:underline">{owner.email}</Link>
<UserStatusBadge status={owner.status} />
</div>
<p className="text-muted-foreground">{owner.name || "未设置昵称"}</p>
<p className="break-all font-mono text-xs text-muted-foreground">{owner.id}</p>
</div>
</div>
<div className="rounded-lg border border-border/70 bg-muted/25 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="size-4 text-primary" />
</div>
<div className="space-y-1 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{subscription.plan.name}</span>
<SubscriptionTypeBadge type={subscription.plan.type} />
<SubscriptionStatusBadge status={subscription.status} />
</div>
<p className="text-muted-foreground">{formatDateShort(subscription.endDate)}</p>
<p className="break-all font-mono text-xs text-muted-foreground">{subscription.id}</p>
</div>
</div>
</div>
{riskEvents.length > 0 && (
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/8 p-3 text-sm">
<div className="flex items-center gap-2 font-semibold text-amber-700 dark:text-amber-300">
<AlertTriangle className="size-4" />
</div>
<div className="grid gap-2">
{riskEvents.map((event) => (
<div key={event.id} className="rounded-md border border-border/50 bg-background/55 p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>{reasonLabel(event.reason)}</StatusBadge>
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)} · {kindLabel(event.kind)}</span>
</div>
<p className="text-sm leading-6 text-foreground/85">{event.message}</p>
{(event.reviewedByEmail || event.reviewNote) && (
<div className="mt-2 rounded-md bg-muted/45 p-2 text-xs leading-5 text-muted-foreground">
{event.reviewedByEmail && <p>{event.reviewedByEmail}{event.reviewedAt ? ` · ${formatDate(event.reviewedAt)}` : ""}</p>}
{event.reviewNote && <p className="mt-1 whitespace-pre-wrap text-foreground/75">{event.reviewNote}</p>}
</div>
)}
<div className="mt-3">
<SubscriptionRiskReviewActions
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={canRestoreFromEvent(event, subscription)}
restorableSubscriptionCount={canRestoreFromEvent(event, subscription) ? 1 : 0}
riskReport={event.riskReport}
reportSentAt={event.reportSentAt}
userRestrictionActive={event.userRestrictionActive}
finalAction={event.finalAction}
/>
</div>
</div>
))}
</div>
</div>
)}
<DataTableShell
isEmpty={accessLogs.length === 0}
emptyTitle="暂无订阅访问记录"
emptyDescription="用户客户端拉取订阅后,这里会显示最近访问 IP 与地区。"
>
<DataTable aria-label="订阅访问记录" className="min-w-[980px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>IP</DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>User-Agent</DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{accessLogs.map((log) => (
<DataTableRow key={log.id}>
<DataTableCell className="text-muted-foreground">{formatDate(log.createdAt)}</DataTableCell>
<DataTableCell>{kindLabel(log.kind)}</DataTableCell>
<DataTableCell className="font-mono text-xs">{log.ip}</DataTableCell>
<DataTableCell>{formatLocation(log)}</DataTableCell>
<DataTableCell>
<StatusBadge tone={log.allowed ? "success" : "warning"}>
{log.allowed ? "已放行" : log.reason || "已拦截"}
</StatusBadge>
</DataTableCell>
<DataTableCell className="max-w-sm truncate text-muted-foreground">
{log.userAgent || "—"}
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
</section>
);
}