feats: add url status componet

This commit is contained in:
oiov
2025-06-29 19:27:27 +08:00
parent bbe3dc4956
commit 6406579f03
15 changed files with 376 additions and 176 deletions
+2 -11
View File
@@ -3,9 +3,9 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import { UserRecordStatus } from "@/components/dashboard/status-card";
import UserRecordsList from "../../dashboard/records/record-list";
import UserRecordStatus from "../../dashboard/records/record-statu";
export const metadata = constructMetadata({
title: "DNS Records",
@@ -25,16 +25,7 @@ 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"
/>
<UserRecordStatus action="/api/record/admin" />
<UserRecordsList
user={{
id: user.id,
+6 -1
View File
@@ -8,7 +8,12 @@ export default function DashboardUrlsLoading() {
heading="Manage Short URLs"
text="List and manage short urls"
/>
<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" />
</>
);
-1
View File
@@ -24,7 +24,6 @@ export default async function DashboardPage() {
link="/docs/short-urls"
linkText="short urls"
/>
<UserUrlsList
user={{
id: user.id,
+2 -11
View File
@@ -3,9 +3,9 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import { UserRecordStatus } from "@/components/dashboard/status-card";
import UserRecordsList from "./record-list";
import UserRecordStatus from "./record-statu";
export const metadata = constructMetadata({
title: "DNS Records",
@@ -25,16 +25,7 @@ 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"
/>
<UserRecordStatus action="/api/record" />
<UserRecordsList
user={{
id: user.id,
@@ -1,147 +0,0 @@
"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>
);
}
+6 -1
View File
@@ -8,7 +8,12 @@ export default function DashboardUrlsLoading() {
heading="Manage Short URLs"
text="List and manage short urls"
/>
<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" />
</>
);
@@ -47,6 +47,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { UrlStatus } from "@/components/dashboard/status-card";
import { FormType } from "@/components/forms/record-form";
import { UrlForm } from "@/components/forms/url-form";
import ApiReference from "@/components/shared/api-reference";
@@ -725,11 +726,13 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</div>
<TabsContent className="space-y-3" value="List">
<UrlStatus action={action} />
{rendeSeachInputs()}
{rendeList()}
{rendLogs()}
</TabsContent>
<TabsContent className="space-y-3" value="Grid">
<UrlStatus action={action} />
{rendeSeachInputs()}
{rendeGrid()}
{rendLogs()}
+23
View File
@@ -0,0 +1,23 @@
import { getUrlStatusOptimized } from "@/lib/dto/short-urls";
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 getUrlStatusOptimized(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 { getUrlStatusOptimized } from "@/lib/dto/short-urls";
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 getUrlStatusOptimized(user.id, "USER");
return Response.json(status);
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
}
}
+8 -1
View File
@@ -4,9 +4,16 @@ import CountUp from "react-countup";
import { nFormatter } from "@/lib/utils";
export default function CountUpFn({ count }: { count: number }) {
export default function CountUpFn({
count,
className,
}: {
count: number;
className?: string;
}) {
return (
<CountUp
className={className}
end={count}
duration={2}
redraw={false}
+209
View File
@@ -0,0 +1,209 @@
"use 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";
export interface StatusConfig {
[key: string]: {
color: string;
textColor: string;
gradient: string;
shadow: string;
};
}
// 用户记录状态配置
export const USER_RECORD_STATUS_CONFIG: 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",
},
};
// 短链状态配置
export const URL_STATUS_CONFIG: StatusConfig = {
Actived: {
color: "bg-green-500",
textColor: "text-green-500",
gradient: "from-green-400 to-green-600",
shadow: "shadow-green-500/25",
},
Disabled: {
color: "bg-gray-600",
textColor: "text-gray-600",
gradient: "from-gray-400 to-gray-600",
shadow: "shadow-gray-500/25",
},
Expired: {
color: "bg-red-500",
textColor: "text-red-500",
gradient: "from-red-400 to-red-600",
shadow: "shadow-red-500/25",
},
PasswordProtected: {
color: "bg-blue-500",
textColor: "text-blue-500",
gradient: "from-blue-400 to-blue-600",
shadow: "shadow-blue-500/25",
},
};
// 用户记录状态组件
export function UserRecordStatus({ action }: { action: string }) {
return (
<GenericStatusDashboard
apiEndpoint={action}
statusItems={["Active", "Inactive", "Pending", "Rejected"]}
statusConfig={USER_RECORD_STATUS_CONFIG}
gridCols={4}
/>
);
}
// 短链状态组件
export function UrlStatus({ action }: { action: string }) {
return (
<GenericStatusDashboard
apiEndpoint={action}
statusItems={["Actived", "Disabled", "Expired", "PasswordProtected"]}
statusConfig={URL_STATUS_CONFIG}
gridCols={4}
/>
);
}
export function GenericStatusDashboard({
apiEndpoint,
statusItems,
statusConfig,
gridCols = 4,
}: {
apiEndpoint: string;
statusItems: string[];
statusConfig: StatusConfig;
gridCols?: number;
}) {
const { data, isLoading, error } = useSWR<Record<string, number>>(
`${apiEndpoint}/status`,
fetcher,
);
const gridClasses =
{
2: "grid-cols-2 sm:grid-cols-2 lg:grid-cols-2",
3: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-3",
4: "grid-cols-2 sm:grid-cols-4 lg:grid-cols-4",
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5",
}[gridCols] || "grid-cols-2 sm:grid-cols-4 lg:grid-cols-4";
if (isLoading || error) {
return (
<div className={`grid gap-4 ${gridClasses}`}>
{statusItems.map((_, index) => (
<Skeleton key={index} className="h-[102px] w-full rounded-lg" />
))}
</div>
);
}
return (
data && (
<div className={`grid gap-4 ${gridClasses}`}>
{statusItems.map((status) => (
<StatusInfoCard
key={status}
title={status}
total={data["total"]}
currentTotal={data[status.toLowerCase()]}
statusConfig={statusConfig}
/>
))}
</div>
)
);
}
export function StatusInfoCard({
title,
total,
currentTotal,
statusConfig,
}: {
title: string;
total?: number;
currentTotal: number;
statusConfig: StatusConfig;
}) {
const t = useTranslations("Components");
const config = statusConfig[title];
const percentage =
total && total > 0 && currentTotal > 0 ? (currentTotal / 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-center gap-2 text-xl font-bold">
{nFormatter(currentTotal || 0)}
<p className="mt-1.5 text-xs 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>
);
}
+88
View File
@@ -2,6 +2,7 @@ import { UrlMeta, UserRole } from "@prisma/client";
import { prisma } from "@/lib/db";
import { EXPIRATION_ENUMS } from "../enums";
import { getStartDate } from "../utils";
export interface ShortUrlFormData {
@@ -154,6 +155,93 @@ export async function getUrlClicksByIds(
}
}
export async function getUrlStatus(userId: string, role: UserRole = "USER") {
try {
} catch (error) {
return { status: error };
}
}
export interface UrlStatusStats {
total: number;
actived: number; // 正常可用
disabled: number; // 已禁用
expired: number; // 已过期
passwordProtected: number; // 密码保护
}
function isValidExpirationValue(expiration: string): boolean {
return EXPIRATION_ENUMS.some((item) => item.value === expiration);
}
export async function getUrlStatusOptimized(
userId: string,
role: UserRole = "USER",
): Promise<UrlStatusStats | { status: any }> {
try {
const whereCondition = role === "USER" ? { userId } : {};
const urlRecords = await prisma.userUrl.findMany({
where: whereCondition,
select: {
id: true,
userId: true,
active: true,
expiration: true,
password: true,
createdAt: true,
updatedAt: true,
},
});
const now = Date.now();
const stats: UrlStatusStats = {
total: urlRecords.length,
actived: 0,
disabled: 0,
expired: 0,
passwordProtected: 0,
};
// 遍历记录并分类
urlRecords.forEach((record) => {
const updatedAt = new Date(
record.updatedAt || record.createdAt!,
).getTime();
// 判断是否过期
let isExpired = false;
if (
record.expiration !== "-1" &&
isValidExpirationValue(record.expiration)
) {
const expirationSeconds = Number(record.expiration);
const expirationMilliseconds = expirationSeconds * 1000;
const expirationTime = updatedAt + expirationMilliseconds;
isExpired = now > expirationTime;
}
const isDisabled = record.active === 0;
const hasPassword = Boolean(record.password && record.password.trim());
if (isExpired) {
stats.expired++;
} else if (isDisabled) {
stats.disabled++;
} else if (hasPassword) {
stats.passwordProtected++;
} else {
stats.actived++;
}
});
return stats;
} catch (error) {
console.error("Error getting URL status (optimized):", error);
return { status: error };
}
}
export async function createUserShortUrl(data: ShortUrlFormData) {
try {
const res = await prisma.userUrl.create({
+5 -1
View File
@@ -320,7 +320,11 @@
"retry": "Retry",
"backToHome": "Back to Home",
"goBack": "Go Back",
"contactSupportIfError": "If you believe this is an error, please contact technical support"
"contactSupportIfError": "If you believe this is an error, please contact technical support",
"Actived": "Active",
"Disabled": "Disabled",
"Expired": "Expired",
"PasswordProtected": "Password Protected"
},
"Landing": {
"settings": "Settings",
+5 -1
View File
@@ -320,7 +320,11 @@
"retry": "重试",
"backToHome": "返回首页",
"goBack": "返回上页",
"contactSupportIfError": "如果您认为这是一个错误,请联系技术支持"
"contactSupportIfError": "如果您认为这是一个错误,请联系技术支持",
"Actived": "有效",
"Disabled": "已禁用",
"Expired": "已过期",
"PasswordProtected": "密码保护"
},
"Landing": {
"settings": "设置",
+1 -1
View File
File diff suppressed because one or more lines are too long