feat(record): add status card

This commit is contained in:
oiov
2025-06-24 19:31:34 +08:00
parent dcb8479017
commit c1743c2840
14 changed files with 282 additions and 10 deletions
+6 -1
View File
@@ -8,7 +8,12 @@ export default function DashboardRecordsLoading() {
heading="Manage DNS Records"
text="List and manage records"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
+11
View File
@@ -5,6 +5,7 @@ import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import UserRecordsList from "../../dashboard/records/record-list";
import UserRecordStatus from "../../dashboard/records/record-statu";
export const metadata = constructMetadata({
title: "DNS Records",
@@ -24,6 +25,16 @@ export default async function DashboardPage() {
link="/docs/dns-records"
linkText="DNS records"
/>
<UserRecordStatus
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
}}
action="/api/record/admin"
/>
<UserRecordsList
user={{
id: user.id,
+1 -1
View File
@@ -327,7 +327,7 @@ export default function AppConfigs({}: {}) {
<div className="flex w-full items-start gap-2">
<Textarea
className="h-16 max-h-32 min-h-9 resize-y bg-white dark:bg-neutral-700"
placeholder="1@a.com,2@b.com"
placeholder="example1@wr.do,example2@wr.do"
rows={5}
// defaultValue={configs.catch_all_emails}
value={catchAllEmails}
@@ -8,7 +8,12 @@ export default function DashboardRecordsLoading() {
heading="Manage DNS Records"
text="List and manage records"
/>
<Skeleton className="h-32 w-full rounded-lg" />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
</div>
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
@@ -5,6 +5,7 @@ import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import UserRecordsList from "./record-list";
import UserRecordStatus from "./record-statu";
export const metadata = constructMetadata({
title: "DNS Records",
@@ -24,6 +25,16 @@ export default async function DashboardPage() {
link="/docs/dns-records"
linkText="DNS records"
/>
<UserRecordStatus
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
}}
action="/api/record"
/>
<UserRecordsList
user={{
id: user.id,
@@ -0,0 +1,147 @@
"use client";
import { User } from "@prisma/client";
import { useTranslations } from "next-intl";
import useSWR from "swr";
import { fetcher, nFormatter } from "@/lib/utils";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import CountUp from "@/components/dashboard/count-up";
export interface RecordStatusProps {
user: Pick<User, "id" | "name" | "apiKey" | "email" | "role">;
action: string;
}
export default function UserRecordStatus({ action }: RecordStatusProps) {
const { data, isLoading, error } = useSWR<Record<string, number>>(
`${action}/status`,
fetcher,
);
if (isLoading || error)
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
<Skeleton className="h-[102px] w-full rounded-lg" />
</div>
);
return (
data && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-4">
<StatuInfoCard
title="Active"
total={data["total"]}
monthTotal={data["active"]}
/>
<StatuInfoCard
title="Inactive"
total={data["total"]}
monthTotal={data["inactive"]}
/>
<StatuInfoCard
title="Pending"
total={data["total"]}
monthTotal={data["pending"]}
/>
<StatuInfoCard
title="Rejected"
total={data["total"]}
monthTotal={data["rejected"]}
/>
</div>
)
);
}
export function StatuInfoCard({
title,
total,
monthTotal,
}: {
title: string;
total?: number;
monthTotal: number;
}) {
const t = useTranslations("Components");
const statusConfig = {
Active: {
color: "bg-green-500",
textColor: "text-green-500",
gradient: "from-green-400 to-green-600",
shadow: "shadow-green-500/25",
},
Inactive: {
color: "bg-gray-700",
textColor: "text-gray-700",
gradient: "from-gray-400 to-gray-600",
shadow: "shadow-gray-500/25",
},
Pending: {
color: "bg-yellow-500",
textColor: "text-yellow-500",
gradient: "from-yellow-400 to-yellow-600",
shadow: "shadow-yellow-500/25",
},
Rejected: {
color: "bg-red-500",
textColor: "text-red-500",
gradient: "from-red-400 to-red-600",
shadow: "shadow-red-500/25",
},
};
const config = statusConfig[title];
const percentage = total && total > 0 ? (monthTotal / total) * 100 : 0;
const barHeight = Math.max(8, Math.min(100, percentage));
return (
<Card className="grids group relative animate-fade-in overflow-hidden bg-gray-50/70 p-4 backdrop-blur-lg transition-all duration-300 hover:shadow-lg dark:bg-primary-foreground">
<div className="flex items-end justify-between">
<div className="flex-1">
<div className="text-sm font-medium">{t(title)}</div>
<div className="mt-4 flex items-end gap-2 text-2xl font-bold">
<CountUp count={monthTotal} />
<p className="align-top text-base text-slate-500">
/ {nFormatter(total || 0)}
</p>
</div>
</div>
<div className="ml-4 flex h-16 items-end">
<div className="relative flex flex-col items-center">
{/* 柱状图背景 */}
<div className="relative h-14 w-7 overflow-hidden rounded-lg border border-gray-300/20 bg-gray-200/50 backdrop-blur-sm dark:border-gray-600/20 dark:bg-gray-700/50">
{/* 主柱状图 */}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t ${config.gradient} rounded-lg transition-all duration-700 ease-out ${config.shadow}`}
style={{ height: `${barHeight}%` }}
/>
{/* 光泽效果 */}
<div
className="absolute bottom-0 left-0 w-1/3 rounded-lg bg-gradient-to-t from-white/30 to-white/10 transition-all duration-700 ease-out"
style={{ height: `${barHeight}%` }}
/>
{/* 顶部高光 */}
{barHeight > 15 && (
<div
className="absolute left-0 right-0 h-1 rounded-t-lg bg-white/40"
style={{ top: `${100 - barHeight}%` }}
/>
)}
<span
className={`absolute left-0 top-0.5 scale-75 text-xs font-semibold ${config.textColor}`}
>
{percentage.toFixed(0)}%
</span>
</div>
</div>
</div>
</div>
</Card>
);
}
-1
View File
@@ -7,7 +7,6 @@ import {
import { getDomainsByFeature } from "@/lib/dto/domains";
import { getPlanQuota } from "@/lib/dto/plan";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret } from "@/lib/utils";
+23
View File
@@ -0,0 +1,23 @@
import { getUserRecordStatus } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
});
}
const status = await getUserRecordStatus(user.id, "ADMIN");
return Response.json(status);
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
}
}
+18
View File
@@ -0,0 +1,18 @@
import { getUserRecordStatus } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const status = await getUserRecordStatus(user.id, "USER");
return Response.json(status);
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
import { cn } from "@/lib/utils";
export default function StatusDot({ status }: { status: number }) {
export default function StatusDot({
status,
color = "bg-green-500",
}: {
status: number;
color?: string;
}) {
return (
<div
className={cn(
"h-[9px] w-[9px] rounded-full",
status === 1 ? "animate-pulse bg-green-500" : "bg-yellow-500",
"h-[9px] w-[9px] animate-pulse rounded-full",
status === 1 ? color : "bg-yellow-500",
)}
/>
);
+39
View File
@@ -229,6 +229,45 @@ export async function getUserRecordCount(
}
}
export async function getUserRecordStatus(
userId: string,
role: UserRole = "USER",
) {
const whereCondition = role === "USER" ? { userId } : {};
const statusCounts = await prisma.userRecord.groupBy({
by: ["active"],
where: whereCondition,
_count: {
_all: true,
},
});
const total = await prisma.userRecord.count({
where: whereCondition,
});
const counts = statusCounts.reduce(
(acc, item) => {
// if (!item.active) {
// acc[0] = item._count._all;
// return acc;
// }
acc[item.active ?? 0] = item._count._all;
return acc;
},
{} as Record<number, number>,
);
return {
total,
inactive: counts[0] || 0,
active: counts[1] || 0,
pending: counts[2] || 0,
rejected: counts[3] || 0,
};
}
export async function getUserRecordByTypeNameContent(
userId: string,
type: string,
+5 -1
View File
@@ -300,7 +300,11 @@
"All the time": "All the time",
"No Visits": "No Visits",
"You don't have any visits yet in": "You don't have any visits yet in",
"System Settings": "System Settings"
"System Settings": "System Settings",
"Active": "Active",
"Inactive": "Inactive",
"Pending": "Pending",
"Rejected": "Rejected"
},
"Landing": {
"settings": "Settings",
+5 -1
View File
@@ -300,7 +300,11 @@
"All the time": "所有时间",
"No Visits": "无访问记录",
"You don't have any visits yet in": "您还没有任何访问记录于",
"System Settings": "系统设置"
"System Settings": "系统设置",
"Active": "有效解析",
"Inactive": "无效解析",
"Pending": "审核中",
"Rejected": "已拒绝"
},
"Landing": {
"settings": "设置",
+1 -1
View File
File diff suppressed because one or more lines are too long