style: refine subscription risk review layout

This commit is contained in:
JetSprow
2026-04-29 16:23:23 +10:00
parent 823b31363a
commit 2a3c9959bd
4 changed files with 329 additions and 259 deletions

View File

@@ -1,24 +1,15 @@
import Link from "next/link";
import type { SubscriptionRiskEvent } from "@prisma/client";
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 { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
import { formatDate, formatDateShort } from "@/lib/utils";
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
import type { SubscriptionRiskEventRow } from "../risk-data";
function kindLabel(kind: SubscriptionRiskEvent["kind"]) {
@@ -70,21 +61,50 @@ function finalActionLabel(action: SubscriptionRiskEvent["finalAction"]) {
}
}
function finalActionTone(action: SubscriptionRiskEvent["finalAction"]): StatusTone {
if (action === "RESTORE_ACCESS") return "success";
if (action === "KEEP_RESTRICTED") return "warning";
return "neutral";
}
function MetricStrip({ events }: { events: SubscriptionRiskEventRow[] }) {
const open = events.filter((event) => event.reviewStatus === "OPEN").length;
const suspended = events.filter((event) => event.level === "SUSPENDED").length;
const restricted = events.filter((event) => event.userRestrictionActive).length;
const reports = events.filter((event) => event.reportSentAt).length;
return (
<section className="grid gap-px overflow-hidden rounded-xl border border-border/70 bg-border/70 sm:grid-cols-4">
{[
["本页待处理", open],
["已暂停事件", suspended],
["用户端限制", restricted],
["已发送报告", reports],
].map(([label, value]) => (
<div key={label} className="bg-card px-4 py-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 font-mono text-2xl font-semibold tracking-[-0.03em]">{value}</p>
</div>
))}
</section>
);
}
function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.subscription) {
return (
<div className="space-y-1">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone="info">{kindLabel(event.kind)}</StatusBadge>
</div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-sm text-muted-foreground"></p>
</div>
);
}
return (
<div className="space-y-1">
<Link href={`/admin/subscriptions/${event.subscription.id}`} className="font-medium hover:underline">
<div className="space-y-2">
<Link href={"/admin/subscriptions/" + event.subscription.id} className="break-words font-medium hover:underline">
{event.subscription.plan.name}
</Link>
<div className="flex flex-wrap items-center gap-2">
@@ -96,115 +116,120 @@ function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
);
}
function UserCell({ event }: { event: SubscriptionRiskEventRow }) {
function UserBlock({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.user) {
return <span className="text-muted-foreground"></span>;
return <p className="text-sm text-muted-foreground"></p>;
}
return (
<div className="space-y-1">
<Link href={"/admin/users/" + event.user.id} className="block max-w-56 break-all font-medium text-foreground hover:underline">
<div className="space-y-2">
<Link href={"/admin/users/" + event.user.id} className="block break-all font-medium hover:underline">
{event.user.email}
</Link>
<p className="max-w-52 break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
<p className="break-words text-xs text-muted-foreground">{event.user.name || "未设置昵称"}</p>
<UserStatusBadge status={event.user.status} />
</div>
);
}
export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEventRow[] }) {
function ReviewState({ event }: { event: SubscriptionRiskEventRow }) {
const finalLabel = finalActionLabel(event.finalAction);
return (
<DataTableShell
isEmpty={events.length === 0}
emptyTitle="暂无订阅风控事件"
emptyDescription="订阅链接出现跨城市或跨省份访问异常后,会在这里进入人工跟进队列。"
>
<DataTable aria-label="订阅风控事件" className="min-w-[1180px]">
<DataTableHead>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell>/IP</DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
{events.map((event) => (
<DataTableRow key={event.id}>
<DataTableCell className="whitespace-nowrap text-muted-foreground">
{formatDate(event.createdAt)}
</DataTableCell>
<DataTableCell>
<UserCell event={event} />
</DataTableCell>
<DataTableCell>
<EventScope event={event} />
</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
{reasonLabel(event.reason)}
</StatusBadge>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">{event.message}</p>
</div>
</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<div className="space-y-1 text-sm">
<p className="font-mono text-xs">{event.ip || "未知 IP"}</p>
<p className="text-xs text-muted-foreground">
{event.cityCount} · / {event.regionCount} · {event.countryCount}
</p>
</div>
<SubscriptionRiskGeoDetails summary={event.geoSummary} />
</div>
</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>
{reviewStatusLabel(event.reviewStatus)}
</StatusBadge>
{(event.reportSentAt || event.userRestrictionActive || event.finalAction) && (
<div className="flex flex-wrap gap-1.5">
{event.reportSentAt && <StatusBadge tone="info"></StatusBadge>}
{event.userRestrictionActive && <StatusBadge tone="danger"></StatusBadge>}
{finalActionLabel(event.finalAction) && (
<StatusBadge tone={event.finalAction === "RESTORE_ACCESS" ? "success" : "warning"}>
{finalActionLabel(event.finalAction)}
</StatusBadge>
)}
</div>
)}
{(event.reviewedByEmail || event.reviewNote) && (
<div className="max-w-52 text-xs leading-5 text-muted-foreground">
{event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
{event.reviewedAt && <p>{formatDate(event.reviewedAt)}</p>}
{event.reviewNote && <p className="mt-1 line-clamp-3 whitespace-pre-wrap text-foreground/70">{event.reviewNote}</p>}
</div>
)}
</div>
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<SubscriptionRiskReviewActions
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={event.canRestoreSubscription}
restorableSubscriptionCount={event.restorableSubscriptionCount}
riskReport={event.riskReport}
reportSentAt={event.reportSentAt}
userRestrictionActive={event.userRestrictionActive}
finalAction={event.finalAction}
/>
</div>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>
</DataTable>
</DataTableShell>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
{event.reportSentAt && <StatusBadge tone="info"></StatusBadge>}
{event.userRestrictionActive && <StatusBadge tone="danger"></StatusBadge>}
{finalLabel && <StatusBadge tone={finalActionTone(event.finalAction)}>{finalLabel}</StatusBadge>}
</div>
{(event.reviewedByEmail || event.reviewNote) && (
<div className="border-t border-border/60 pt-3 text-xs leading-5 text-muted-foreground">
{event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
{event.reviewedAt && <p>{formatDate(event.reviewedAt)}</p>}
{event.reviewNote && <p className="mt-1 line-clamp-4 whitespace-pre-wrap text-foreground/70">{event.reviewNote}</p>}
</div>
)}
</div>
);
}
function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
return (
<article className="surface-card overflow-hidden rounded-xl">
<div className="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(25rem,1.2fr)_minmax(18rem,0.65fr)]">
<section className="space-y-5 p-5">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
{reasonLabel(event.reason)}
</StatusBadge>
<StatusBadge tone="neutral">{kindLabel(event.kind)}</StatusBadge>
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)}</span>
</div>
<div className="space-y-2">
<p className="text-sm font-semibold leading-6">{event.message}</p>
<p className="break-all font-mono text-xs text-muted-foreground"> IP{event.ip || "未知 IP"}</p>
</div>
<div className="grid gap-4 border-t border-border/60 pt-4 md:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground"></p>
<UserBlock event={event} />
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground"></p>
<EventScope event={event} />
</div>
</div>
</section>
<section className="border-y border-border/70 bg-muted/10 p-5 xl:border-x xl:border-y-0">
<SubscriptionRiskGeoDetails summary={event.geoSummary} />
</section>
<aside className="space-y-5 p-5">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<ReviewState event={event} />
</div>
<div className="border-t border-border/60 pt-4">
<SubscriptionRiskReviewActions
eventId={event.id}
reviewStatus={event.reviewStatus}
canRestoreSubscription={event.canRestoreSubscription}
restorableSubscriptionCount={event.restorableSubscriptionCount}
riskReport={event.riskReport}
reportSentAt={event.reportSentAt}
userRestrictionActive={event.userRestrictionActive}
finalAction={event.finalAction}
/>
</div>
</aside>
</div>
</article>
);
}
export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEventRow[] }) {
if (events.length === 0) {
return (
<EmptyState
title="暂无订阅风控事件"
description="订阅链接出现跨城市、跨省份或跨国家访问异常后,会在这里进入人工跟进队列。"
/>
);
}
return (
<div className="space-y-4">
<MetricStrip events={events} />
<div className="space-y-4">
{events.map((event) => (
<RiskEventCard key={event.id} event={event} />
))}
</div>
</div>
);
}