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,5 +1,6 @@
import { ChevronDown, Globe2, MapPin } from "lucide-react"; import { ChevronDown, Globe2, MapPin } from "lucide-react";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { WORLD_COUNTRY_PATHS } from "@/components/shared/world-map-paths";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import type { SubscriptionRiskGeoSummary } from "@/services/subscription-risk-review"; import type { SubscriptionRiskGeoSummary } from "@/services/subscription-risk-review";
@@ -11,122 +12,137 @@ function projectPoint(latitude: number, longitude: number) {
} }
function radiusForAccess(count: number) { function radiusForAccess(count: number) {
return Math.min(7, Math.max(3, 2 + Math.sqrt(count))); return Math.min(7.5, Math.max(3.2, 2.4 + Math.sqrt(count)));
}
function normalizeCountryName(value: string) {
const normalized = value.trim().toLowerCase();
const aliases: Record<string, string> = {
"united states": "united states of america",
usa: "united states of america",
"u.s.": "united states of america",
"u.s.a.": "united states of america",
russia: "russia",
"russian federation": "russia",
vietnam: "vietnam",
"viet nam": "vietnam",
"south korea": "south korea",
korea: "south korea",
czechia: "czechia",
"czech republic": "czechia",
};
return aliases[normalized] ?? normalized;
} }
function WorldRiskMap({ summary }: { summary: SubscriptionRiskGeoSummary }) { function WorldRiskMap({ summary }: { summary: SubscriptionRiskGeoSummary }) {
const points = summary.points.slice(0, 60); const points = summary.points.slice(0, 80);
const activeCountries = new Set(summary.countries.map((country) => normalizeCountryName(country.country)));
return ( return (
<div className="overflow-hidden rounded-lg border border-border/70 bg-muted/20"> <div className="overflow-hidden rounded-xl border border-border/70 bg-card">
<svg <svg
viewBox="0 0 360 180" viewBox="0 0 360 180"
className="h-48 w-full" className="h-[15rem] w-full bg-[radial-gradient(circle_at_30%_20%,color-mix(in_oklch,var(--primary)_10%,transparent),transparent_30%),linear-gradient(135deg,var(--muted),var(--card))]"
role="img" role="img"
aria-label="订阅访问 IP 世界地图分布" aria-label="订阅访问 IP 世界地图分布"
> >
<defs> <rect width="360" height="180" rx="12" fill="transparent" />
<linearGradient id="risk-map-water" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="var(--muted)" stopOpacity="0.68" />
<stop offset="100%" stopColor="var(--card)" stopOpacity="0.94" />
</linearGradient>
</defs>
<rect width="360" height="180" rx="12" fill="url(#risk-map-water)" />
{[-120, -60, 0, 60, 120].map((longitude) => { {[-120, -60, 0, 60, 120].map((longitude) => {
const x = ((longitude + 180) / 360) * 360; const x = ((longitude + 180) / 360) * 360;
return <line key={longitude} x1={x} x2={x} y1="12" y2="168" stroke="var(--border)" strokeDasharray="2 5" strokeOpacity="0.7" />; return <line key={longitude} x1={x} x2={x} y1="10" y2="170" stroke="var(--border)" strokeDasharray="1 7" strokeOpacity="0.72" />;
})} })}
{[-45, 0, 45].map((latitude) => { {[-45, 0, 45].map((latitude) => {
const y = ((90 - latitude) / 180) * 180; const y = ((90 - latitude) / 180) * 180;
return <line key={latitude} x1="12" x2="348" y1={y} y2={y} stroke="var(--border)" strokeDasharray="2 5" strokeOpacity="0.7" />; return <line key={latitude} x1="10" x2="350" y1={y} y2={y} stroke="var(--border)" strokeDasharray="1 7" strokeOpacity="0.72" />;
})}
<g>
{WORLD_COUNTRY_PATHS.map((country) => {
const active = activeCountries.has(normalizeCountryName(country.name));
return (
<path
key={country.isoA2 + country.name}
d={country.path}
fill={active ? "color-mix(in oklch, var(--primary) 24%, var(--card))" : "color-mix(in oklch, var(--foreground) 7%, var(--card))"}
stroke="var(--border)"
strokeWidth={active ? 0.45 : 0.35}
opacity={active ? 0.96 : 0.72}
/>
);
})} })}
<g fill="var(--card)" stroke="var(--border)" strokeWidth="1" opacity="0.92">
<path d="M39 50 61 35 91 39 116 57 106 78 82 80 72 106 54 97 36 69Z" />
<path d="M86 93 105 103 113 129 100 158 82 144 75 118Z" />
<path d="M132 47 160 36 191 41 215 35 251 48 286 58 303 79 276 95 246 89 220 102 190 91 169 98 144 82Z" />
<path d="M174 89 198 98 208 126 195 158 172 139 158 107Z" />
<path d="M281 116 306 110 326 126 316 147 289 145 270 130Z" />
<path d="M295 72 330 69 342 83 325 94 300 89Z" />
<path d="M126 33 146 26 168 32 151 42Z" />
</g> </g>
<g> <g>
{points.map((point) => { {points.map((point) => {
const { x, y } = projectPoint(point.latitude, point.longitude); const { x, y } = projectPoint(point.latitude, point.longitude);
const color = point.allowed ? "var(--primary)" : "var(--destructive)";
return ( return (
<g key={point.key}> <g key={point.key}>
<title>{point.ip + " / " + point.country + " / " + point.region + " / " + point.city}</title> <title>{point.ip + " / " + point.country + " / " + point.region + " / " + point.city}</title>
<circle <circle cx={x} cy={y} r={radiusForAccess(point.accessCount) + 4} fill={color} opacity="0.13" />
cx={x} <circle cx={x} cy={y} r={radiusForAccess(point.accessCount)} fill={color} stroke="var(--card)" strokeWidth="1.6" />
cy={y} <circle cx={x} cy={y} r="1.3" fill="var(--card)" opacity="0.9" />
r={radiusForAccess(point.accessCount) + 2}
fill={point.allowed ? "var(--primary)" : "var(--destructive)"}
opacity="0.16"
/>
<circle
cx={x}
cy={y}
r={radiusForAccess(point.accessCount)}
fill={point.allowed ? "var(--primary)" : "var(--destructive)"}
stroke="var(--card)"
strokeWidth="1.5"
/>
</g> </g>
); );
})} })}
</g> </g>
</svg> </svg>
<div className="flex items-center justify-between border-t border-border/60 px-3 py-2 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 px-3 py-2 text-xs text-muted-foreground">
<span>{points.length > 0 ? "已标注 " + points.length + " 个坐标点" : "没有可用经纬度坐标"}</span> <span>{points.length > 0 ? "真实边界地图 · 已标注 " + points.length + " 个坐标点" : "真实边界地图 · 没有可用经纬度坐标"}</span>
<span>访</span> <span>访</span>
</div> </div>
</div> </div>
); );
} }
function RiskMetric({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0 border-t border-border/60 pt-3 first:border-t-0 first:pt-0 sm:border-l sm:border-t-0 sm:pl-3 sm:pt-0 sm:first:border-l-0 sm:first:pl-0">
<p className="text-[0.7rem] text-muted-foreground">{label}</p>
<p className="mt-1 font-mono text-lg font-semibold leading-none">{value}</p>
</div>
);
}
export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionRiskGeoSummary }) { export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionRiskGeoSummary }) {
return ( return (
<details className="group min-w-[24rem] rounded-lg border border-border/70 bg-muted/20 text-sm"> <section className="space-y-4">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 [&::-webkit-details-marker]:hidden"> <div className="flex items-center justify-between gap-3">
<span className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background text-primary"> <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Globe2 className="size-4" /> <Globe2 className="size-4" />
</span> </span>
<span className="min-w-0"> <div className="min-w-0">
<span className="block font-medium"> IP </span> <h3 className="text-sm font-semibold"></h3>
<span className="block truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground"> IP </p>
{summary.uniqueCountryCount} / {summary.uniqueRegionCount} / {summary.uniqueCityCount} / {summary.uniqueIpCount} IP </div>
</span> </div>
</span> <StatusBadge tone={summary.uniqueCountryCount > 1 ? "danger" : summary.uniqueRegionCount > 1 ? "warning" : "info"}>
</span> {summary.uniqueCountryCount}
<ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" /> </StatusBadge>
</summary> </div>
<div className="border-t border-border/60 p-3"> <div className="grid gap-4 2xl:grid-cols-[minmax(0,1.25fr)_minmax(15rem,0.75fr)]">
<div className="grid gap-3 xl:grid-cols-[minmax(20rem,1.1fr)_minmax(16rem,0.9fr)]">
<WorldRiskMap summary={summary} /> <WorldRiskMap summary={summary} />
<div className="space-y-2"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-2"> <div className="grid gap-3 sm:grid-cols-4 2xl:grid-cols-2">
<div className="rounded-md border border-border/60 bg-background/70 p-2"> <RiskMetric label="访问" value={summary.totalLogs} />
<p className="text-xs text-muted-foreground">访</p> <RiskMetric label="IP" value={summary.uniqueIpCount} />
<p className="mt-1 font-mono text-base font-semibold">{summary.totalLogs}</p> <RiskMetric label="省区" value={summary.uniqueRegionCount} />
</div> <RiskMetric label="城市" value={summary.uniqueCityCount} />
<div className="rounded-md border border-border/60 bg-background/70 p-2">
<p className="text-xs text-muted-foreground"> IP</p>
<p className="mt-1 font-mono text-base font-semibold">{summary.uniqueIpCount}</p>
</div>
</div> </div>
<div className="max-h-44 space-y-2 overflow-auto pr-1"> <div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<div className="max-h-40 space-y-2 overflow-auto pr-1">
{summary.countries.length === 0 ? ( {summary.countries.length === 0 ? (
<p className="rounded-md border border-dashed border-border/70 p-3 text-xs text-muted-foreground"> <p className="rounded-lg border border-dashed border-border/70 p-3 text-xs leading-5 text-muted-foreground">
GeoIP 访 IP GeoIP
</p> </p>
) : ( ) : (
summary.countries.map((country) => ( summary.countries.map((country) => (
<div key={country.country} className="rounded-md border border-border/60 bg-background/70 p-2"> <div key={country.country} className="border-t border-border/60 pt-2 first:border-t-0 first:pt-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-3">
<p className="break-words font-medium">{country.country}</p> <p className="break-words text-sm font-medium">{country.country}</p>
<span className="shrink-0 font-mono text-xs text-muted-foreground">{country.accessCount} </span> <span className="shrink-0 font-mono text-xs text-muted-foreground">{country.accessCount} </span>
</div> </div>
<p className="mt-1 text-xs leading-5 text-muted-foreground"> <p className="mt-1 text-xs leading-5 text-muted-foreground">
@@ -134,7 +150,7 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
</p> </p>
{(country.topRegions.length > 0 || country.topCities.length > 0) && ( {(country.topRegions.length > 0 || country.topCities.length > 0) && (
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground"> <p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{country.topRegions.join("、") || "未识别"}{country.topCities.join("、") || "未识别"} {country.topRegions.join("、") || "未识别省区"} / {country.topCities.join("、") || "未识别城市"}
</p> </p>
)} )}
</div> </div>
@@ -143,19 +159,23 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
</div> </div>
</div> </div>
</div> </div>
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<MapPin className="size-3.5" /> 访
</div> </div>
<div className="grid gap-2 lg:grid-cols-2">
<details className="group rounded-xl border border-border/70 bg-muted/20">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2 text-sm font-medium [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2">
<MapPin className="size-4 text-primary" /> IP 访
</span>
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="grid gap-2 border-t border-border/60 p-3 lg:grid-cols-2">
{summary.recentAccesses.length === 0 ? ( {summary.recentAccesses.length === 0 ? (
<p className="rounded-md border border-dashed border-border/70 p-3 text-xs text-muted-foreground">访</p> <p className="rounded-lg border border-dashed border-border/70 p-3 text-xs text-muted-foreground">访</p>
) : ( ) : (
summary.recentAccesses.slice(0, 6).map((access) => ( summary.recentAccesses.slice(0, 8).map((access) => (
<div key={access.id} className="min-w-0 rounded-md border border-border/60 bg-background/70 p-2 text-xs leading-5"> <div key={access.id} className="min-w-0 border-t border-border/60 pt-2 text-xs leading-5 first:border-t-0 first:pt-0 lg:odd:border-t-0 lg:odd:pt-0 lg:even:border-t-0 lg:even:pt-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="truncate font-mono">{access.ip}</span> <span className="truncate font-mono text-foreground">{access.ip}</span>
<StatusBadge tone={access.allowed ? "success" : "warning"}> <StatusBadge tone={access.allowed ? "success" : "warning"}>
{access.allowed ? "放行" : access.reason || "拦截"} {access.allowed ? "放行" : access.reason || "拦截"}
</StatusBadge> </StatusBadge>
@@ -167,8 +187,7 @@ export function SubscriptionRiskGeoDetails({ summary }: { summary: SubscriptionR
)) ))
)} )}
</div> </div>
</div>
</div>
</details> </details>
</section>
); );
} }

View File

@@ -1,24 +1,15 @@
import Link from "next/link"; import Link from "next/link";
import type { SubscriptionRiskEvent } from "@prisma/client"; 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 { import {
SubscriptionStatusBadge, SubscriptionStatusBadge,
SubscriptionTypeBadge, SubscriptionTypeBadge,
UserStatusBadge, UserStatusBadge,
} from "@/components/shared/domain-badges"; } from "@/components/shared/domain-badges";
import { EmptyState } from "@/components/shared/page-shell";
import { StatusBadge, type StatusTone } from "@/components/shared/status-badge"; import { StatusBadge, type StatusTone } from "@/components/shared/status-badge";
import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions"; import { SubscriptionRiskReviewActions } from "@/components/subscriptions/subscription-risk-review-actions";
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
import { formatDate, formatDateShort } from "@/lib/utils"; import { formatDate, formatDateShort } from "@/lib/utils";
import { SubscriptionRiskGeoDetails } from "./subscription-risk-geo-details";
import type { SubscriptionRiskEventRow } from "../risk-data"; import type { SubscriptionRiskEventRow } from "../risk-data";
function kindLabel(kind: SubscriptionRiskEvent["kind"]) { 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 }) { function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.subscription) { if (!event.subscription) {
return ( return (
<div className="space-y-1"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<StatusBadge tone="info">{kindLabel(event.kind)}</StatusBadge> <StatusBadge tone="info">{kindLabel(event.kind)}</StatusBadge>
</div> </div>
<p className="text-xs text-muted-foreground"></p> <p className="text-sm text-muted-foreground"></p>
</div> </div>
); );
} }
return ( return (
<div className="space-y-1"> <div className="space-y-2">
<Link href={`/admin/subscriptions/${event.subscription.id}`} className="font-medium hover:underline"> <Link href={"/admin/subscriptions/" + event.subscription.id} className="break-words font-medium hover:underline">
{event.subscription.plan.name} {event.subscription.plan.name}
</Link> </Link>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -96,99 +116,85 @@ function EventScope({ event }: { event: SubscriptionRiskEventRow }) {
); );
} }
function UserCell({ event }: { event: SubscriptionRiskEventRow }) { function UserBlock({ event }: { event: SubscriptionRiskEventRow }) {
if (!event.user) { if (!event.user) {
return <span className="text-muted-foreground"></span>; return <p className="text-sm text-muted-foreground"></p>;
} }
return ( return (
<div className="space-y-1"> <div className="space-y-2">
<Link href={"/admin/users/" + event.user.id} className="block max-w-56 break-all font-medium text-foreground hover:underline"> <Link href={"/admin/users/" + event.user.id} className="block break-all font-medium hover:underline">
{event.user.email} {event.user.email}
</Link> </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} /> <UserStatusBadge status={event.user.status} />
</div> </div>
); );
} }
export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEventRow[] }) { function ReviewState({ event }: { event: SubscriptionRiskEventRow }) {
const finalLabel = finalActionLabel(event.finalAction);
return ( return (
<DataTableShell <div className="space-y-3">
isEmpty={events.length === 0} <div className="flex flex-wrap gap-2">
emptyTitle="暂无订阅风控事件" <StatusBadge tone={reviewStatusTone(event.reviewStatus)}>{reviewStatusLabel(event.reviewStatus)}</StatusBadge>
emptyDescription="订阅链接出现跨城市或跨省份访问异常后,会在这里进入人工跟进队列。" {event.reportSentAt && <StatusBadge tone="info"></StatusBadge>}
> {event.userRestrictionActive && <StatusBadge tone="danger"></StatusBadge>}
<DataTable aria-label="订阅风控事件" className="min-w-[1180px]"> {finalLabel && <StatusBadge tone={finalActionTone(event.finalAction)}>{finalLabel}</StatusBadge>}
<DataTableHead> </div>
<DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell> {(event.reviewedByEmail || event.reviewNote) && (
<DataTableHeadCell></DataTableHeadCell> <div className="border-t border-border/60 pt-3 text-xs leading-5 text-muted-foreground">
<DataTableHeadCell></DataTableHeadCell> {event.reviewedByEmail && <p className="break-all">{event.reviewedByEmail}</p>}
<DataTableHeadCell></DataTableHeadCell> {event.reviewedAt && <p>{formatDate(event.reviewedAt)}</p>}
<DataTableHeadCell>/IP</DataTableHeadCell> {event.reviewNote && <p className="mt-1 line-clamp-4 whitespace-pre-wrap text-foreground/70">{event.reviewNote}</p>}
<DataTableHeadCell></DataTableHeadCell> </div>
<DataTableHeadCell className="text-right"></DataTableHeadCell> )}
</DataTableHeaderRow> </div>
</DataTableHead> );
<DataTableBody> }
{events.map((event) => (
<DataTableRow key={event.id}> function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
<DataTableCell className="whitespace-nowrap text-muted-foreground"> return (
{formatDate(event.createdAt)} <article className="surface-card overflow-hidden rounded-xl">
</DataTableCell> <div className="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(25rem,1.2fr)_minmax(18rem,0.65fr)]">
<DataTableCell> <section className="space-y-5 p-5">
<UserCell event={event} /> <div className="flex flex-wrap items-center gap-2">
</DataTableCell>
<DataTableCell>
<EventScope event={event} />
</DataTableCell>
<DataTableCell>
<div className="space-y-2">
<StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}> <StatusBadge tone={event.level === "SUSPENDED" ? "danger" : "warning"}>
{reasonLabel(event.reason)} {reasonLabel(event.reason)}
</StatusBadge> </StatusBadge>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">{event.message}</p> <StatusBadge tone="neutral">{kindLabel(event.kind)}</StatusBadge>
<span className="text-xs text-muted-foreground">{formatDate(event.createdAt)}</span>
</div> </div>
</DataTableCell>
<DataTableCell>
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-1 text-sm"> <p className="text-sm font-semibold leading-6">{event.message}</p>
<p className="font-mono text-xs">{event.ip || "未知 IP"}</p> <p className="break-all font-mono text-xs text-muted-foreground"> IP{event.ip || "未知 IP"}</p>
<p className="text-xs text-muted-foreground">
{event.cityCount} · / {event.regionCount} · {event.countryCount}
</p>
</div> </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} /> <SubscriptionRiskGeoDetails summary={event.geoSummary} />
</div> </section>
</DataTableCell>
<DataTableCell> <aside className="space-y-5 p-5">
<div className="space-y-2"> <div className="space-y-2">
<StatusBadge tone={reviewStatusTone(event.reviewStatus)}> <p className="text-xs font-medium text-muted-foreground"></p>
{reviewStatusLabel(event.reviewStatus)} <ReviewState event={event} />
</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> </div>
)} <div className="border-t border-border/60 pt-4">
{(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 <SubscriptionRiskReviewActions
eventId={event.id} eventId={event.id}
reviewStatus={event.reviewStatus} reviewStatus={event.reviewStatus}
@@ -200,11 +206,30 @@ export function SubscriptionRiskTable({ events }: { events: SubscriptionRiskEven
finalAction={event.finalAction} finalAction={event.finalAction}
/> />
</div> </div>
</DataTableCell> </aside>
</DataTableRow> </div>
))} </article>
</DataTableBody> );
</DataTable> }
</DataTableShell>
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>
); );
} }

File diff suppressed because one or more lines are too long

View File

@@ -197,30 +197,35 @@ export function SubscriptionRiskReviewActions({
return ( return (
<> <>
<div className="flex max-w-72 flex-wrap justify-end gap-2"> <div className="space-y-3">
<Button <div className="space-y-2">
size="sm" <p className="text-xs font-medium text-muted-foreground"></p>
variant="outline" <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
disabled={pending} <Button size="sm" variant="outline" disabled={pending} onClick={() => handleGenerateReport(true)}>
onClick={() => handleGenerateReport(true)}
>
<FileText className="size-4" /> <FileText className="size-4" />
{reportPreview ? "重生成报告" : "生成报告"} {reportPreview ? "重生成" : "生成报告"}
</Button> </Button>
{reportPreview && ( {reportPreview && (
<Button size="sm" variant="ghost" disabled={pending} onClick={() => setDialog({ type: "report" })}> <Button size="sm" variant="ghost" disabled={pending} onClick={() => setDialog({ type: "report" })}>
</Button> </Button>
)} )}
<Button size="sm" variant="outline" disabled={pending} onClick={handleSendReport}> <Button size="sm" variant="outline" disabled={pending} onClick={handleSendReport} className={reportPreview ? "sm:col-span-2 xl:col-span-1 2xl:col-span-2" : ""}>
<Send className="size-4" /> <Send className="size-4" />
{reportSentAt ? "重新发送" : "发送用户"} {reportSentAt ? "重新发送用户" : "发送用户"}
</Button> </Button>
</div>
</div>
<div className="space-y-2 border-t border-border/60 pt-3">
<p className="text-xs font-medium text-muted-foreground"></p>
<div className="grid gap-2">
<Button <Button
size="sm" size="sm"
variant={canRestoreSubscription || userRestrictionActive ? "default" : "outline"} variant={canRestoreSubscription || userRestrictionActive ? "default" : "outline"}
disabled={pending || (!canRestoreSubscription && !userRestrictionActive && reviewStatus === "RESOLVED")} disabled={pending || (!canRestoreSubscription && !userRestrictionActive && reviewStatus === "RESOLVED")}
onClick={() => openFinalDialog("RESTORE_ACCESS")} onClick={() => openFinalDialog("RESTORE_ACCESS")}
className="justify-start"
> >
<UnlockKeyhole className="size-4" /> <UnlockKeyhole className="size-4" />
@@ -230,10 +235,18 @@ export function SubscriptionRiskReviewActions({
variant="destructive" variant="destructive"
disabled={pending || finalAction === "KEEP_RESTRICTED"} disabled={pending || finalAction === "KEEP_RESTRICTED"}
onClick={() => openFinalDialog("KEEP_RESTRICTED")} onClick={() => openFinalDialog("KEEP_RESTRICTED")}
className="justify-start"
> >
<LockKeyhole className="size-4" /> <LockKeyhole className="size-4" />
/
</Button> </Button>
</div>
</div>
{availableModes.length > 0 && (
<div className="space-y-2 border-t border-border/60 pt-3">
<p className="text-xs font-medium text-muted-foreground"></p>
<div className="flex flex-wrap gap-2">
{availableModes.map((item) => ( {availableModes.map((item) => (
<Button <Button
key={item.status} key={item.status}
@@ -247,6 +260,9 @@ export function SubscriptionRiskReviewActions({
</Button> </Button>
))} ))}
</div> </div>
</div>
)}
</div>
<Dialog open={dialog != null} onOpenChange={(open) => !pending && !open && setDialog(null)}> <Dialog open={dialog != null} onOpenChange={(open) => !pending && !open && setDialog(null)}>
<DialogContent className={dialog?.type === "report" ? "sm:max-w-3xl" : "sm:max-w-lg"}> <DialogContent className={dialog?.type === "report" ? "sm:max-w-3xl" : "sm:max-w-lg"}>