add growth rate feature and send email list

This commit is contained in:
oiov
2025-04-29 18:37:27 +08:00
parent b0a0e4a200
commit 2a067c6e65
15 changed files with 3014 additions and 30 deletions
+2 -2
View File
@@ -276,10 +276,10 @@ export default async function AdminPage() {
</Suspense>
</ErrorBoundary>
<ErrorBoundary
fallback={<Skeleton className="h-[342px] w-full rounded-lg" />}
fallback={<Skeleton className="min-h-[342px] w-full rounded-lg" />}
>
<Suspense
fallback={<Skeleton className="h-[342px] w-full rounded-lg" />}
fallback={<Skeleton className="min-h-[342px] w-full rounded-lg" />}
>
<RequestStatsSection />
</Suspense>
@@ -105,7 +105,7 @@ export function DailyPVUVChart({ data }: { data: ScrapeMeta[] }) {
const latestFrom = latestEntry.type;
return (
<Card className="">
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3">
<CardTitle>Total Requests of APIs</CardTitle>
+154 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { checkUserStatus } from "@/lib/dto/user";
import { TIME_RANGES } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { getStartDate } from "@/lib/utils";
@@ -20,6 +21,14 @@ export async function GET(req: Request) {
const range = url.searchParams.get("range") || "7d";
const startDate = getStartDate(range);
if (!startDate) {
return Response.json({ statusText: "Invalid range" }, { status: 400 });
}
// Calculate previous period start and end dates
const rangeDuration = TIME_RANGES[range];
const prevStartDate = new Date(startDate.getTime() - rangeDuration);
const prevEndDate = startDate;
const users = await prisma.user.findMany({
where: {
@@ -86,12 +95,95 @@ export async function GET(req: Request) {
createdAt: true,
},
});
const sends = await prisma.userSendEmail.findMany({
where: {
createdAt: {
gte: startDate,
},
},
orderBy: {
createdAt: "desc",
},
select: {
createdAt: true,
},
});
// Fetch previous period data
const prevUsers = await prisma.user.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
const prevRecords = await prisma.userRecord.findMany({
where: {
created_on: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
created_on: true,
},
});
const prevUrls = await prisma.userUrl.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
const prevEmails = await prisma.userEmail.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
const prevInbox = await prisma.forwardEmail.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
const prevSends = await prisma.userSendEmail.findMany({
where: {
createdAt: {
gte: prevStartDate,
lt: prevEndDate,
},
},
select: {
createdAt: true,
},
});
// Process current period data
const userCountByDate: { [date: string]: number } = {};
const recordCountByDate: { [date: string]: number } = {};
const urlCountByDate: { [date: string]: number } = {};
const emailCountByDate: { [date: string]: number } = {};
const inboxCountByDate: { [date: string]: number } = {};
const sendCountByDate: { [date: string]: number } = {};
users.forEach((user) => {
const date = user.createdAt!.toISOString().split("T")[0];
@@ -113,6 +205,10 @@ export async function GET(req: Request) {
const date = email.createdAt.toISOString().split("T")[0];
inboxCountByDate[date] = (inboxCountByDate[date] || 0) + 1;
});
sends.forEach((send) => {
const date = send.createdAt.toISOString().split("T")[0];
sendCountByDate[date] = (sendCountByDate[date] || 0) + 1;
});
const allDates = Array.from(
new Set([
@@ -121,6 +217,7 @@ export async function GET(req: Request) {
...Object.keys(urlCountByDate),
...Object.keys(emailCountByDate),
...Object.keys(inboxCountByDate),
...Object.keys(sendCountByDate),
]),
);
const combinedData = allDates.map((date) => ({
@@ -130,6 +227,7 @@ export async function GET(req: Request) {
users: userCountByDate[date] || 0,
emails: emailCountByDate[date] || 0,
inbox: inboxCountByDate[date] || 0,
sends: sendCountByDate[date] || 0,
}));
const total = {
@@ -138,9 +236,64 @@ export async function GET(req: Request) {
users: combinedData.reduce((acc, curr) => acc + curr.users, 0),
emails: combinedData.reduce((acc, curr) => acc + curr.emails, 0),
inbox: combinedData.reduce((acc, curr) => acc + curr.inbox, 0),
sends: combinedData.reduce((acc, curr) => acc + curr.sends, 0),
};
return Response.json({ list: combinedData.reverse(), total });
// Calculate totals for previous period
const prevTotal = {
records: prevRecords.length,
urls: prevUrls.length,
users: prevUsers.length,
emails: prevEmails.length,
inbox: prevInbox.length,
sends: prevSends.length,
};
// Calculate growth rates
const growthRates = {
records:
prevTotal.records === 0
? total.records > 0
? 100
: 0
: ((total.records - prevTotal.records) / prevTotal.records) * 100,
urls:
prevTotal.urls === 0
? total.urls > 0
? 100
: 0
: ((total.urls - prevTotal.urls) / prevTotal.urls) * 100,
users:
prevTotal.users === 0
? total.users > 0
? 100
: 0
: ((total.users - prevTotal.users) / prevTotal.users) * 100,
emails:
prevTotal.emails === 0
? total.emails > 0
? 100
: 0
: ((total.emails - prevTotal.emails) / prevTotal.emails) * 100,
inbox:
prevTotal.inbox === 0
? total.inbox > 0
? 100
: 0
: ((total.inbox - prevTotal.inbox) / prevTotal.inbox) * 100,
sends:
prevTotal.sends === 0
? total.sends > 0
? 100
: 0
: ((total.sends - prevTotal.sends) / prevTotal.sends) * 100,
};
return Response.json({
list: combinedData.reverse(),
total,
growthRates,
});
} catch (error) {
return Response.json({ statusText: "Server error" }, { status: 500 });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getUserSendEmailList } from "@/lib/dto/email";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { searchParams } = new URL(req.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const size = parseInt(searchParams.get("size") || "10", 10);
const search = searchParams.get("search") || "";
const all = searchParams.get("all") || "false";
const data = await getUserSendEmailList(
user.id,
user.role === "ADMIN" && all === "true",
page,
size,
search,
);
return NextResponse.json(data);
} catch (error) {
return NextResponse.json("Internal server error", { status: 500 });
}
}
+51 -20
View File
@@ -6,7 +6,7 @@ import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import useSWR from "swr";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { fetcher } from "@/lib/utils";
import { cn, fetcher } from "@/lib/utils";
import {
Card,
CardContent,
@@ -54,6 +54,10 @@ const chartConfig = {
label: "Inbox",
color: "hsl(var(--chart-1))",
},
sends: {
label: "Sends",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export function InteractiveBarChart() {
@@ -69,6 +73,7 @@ export function InteractiveBarChart() {
users: number;
emails: number;
inbox: number;
sends: number;
date: string;
},
];
@@ -78,6 +83,15 @@ export function InteractiveBarChart() {
users: number;
emails: number;
inbox: number;
sends: number;
};
growthRates: {
records: number;
urls: number;
users: number;
emails: number;
inbox: number;
sends: number;
};
}>(`api/admin?range=${timeRange}`, fetcher, {
revalidateOnFocus: false,
@@ -94,7 +108,7 @@ export function InteractiveBarChart() {
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Data Increase</CardTitle>
<CardDescription>
Showing data increase in
Showing data increase in:
<Select
onValueChange={(value: string) => {
setTimeRange(value);
@@ -117,24 +131,41 @@ export function InteractiveBarChart() {
</div>
<div className="flex">
{["users", "records", "urls", "emails", "inbox"].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-l border-t p-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:p-6"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none sm:text-3xl">
{data.total[key as keyof typeof data.total].toLocaleString()}
</span>
</button>
);
})}
{["users", "records", "urls", "emails", "inbox", "sends"].map(
(key) => {
const chart = key as keyof typeof chartConfig;
const growthRate =
data.growthRates[key as keyof typeof data.growthRates];
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-l border-t p-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:p-6"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none sm:text-3xl">
{data.total[
key as keyof typeof data.total
].toLocaleString()}
</span>
<span
className={cn(
"rounded px-1.5 py-1 text-xs font-semibold leading-none",
growthRate > 0 && "bg-green-200 text-green-700",
growthRate < 0 && "bg-red-200 text-red-700",
growthRate === 0 && "bg-neutral-100 text-neutral-700",
)}
>
{growthRate >= 0 ? "+" : ""}
{growthRate.toFixed(1)}%
</span>
</button>
);
},
)}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
+23 -1
View File
@@ -47,6 +47,7 @@ import {
TooltipTrigger,
} from "../ui/tooltip";
import { SendEmailModal } from "./SendEmailModal";
import SendsEmailList from "./SendsEmailList";
interface EmailSidebarProps {
user: User;
@@ -85,6 +86,8 @@ export default function EmailSidebar({
const [currentPage, setCurrentPage] = useState(1);
const [onlyUnread, setOnlyUnread] = useState(false);
const [showSendsModal, setShowSendsModal] = useState(false);
const pageSize = 10;
const { data, isLoading, error, mutate } = useSWR<{
@@ -355,7 +358,16 @@ export default function EmailSidebar({
</div>
{/* Sent Emails */}
<div className="flex cursor-pointer flex-col items-center gap-1 rounded-md bg-neutral-100 px-1 pb-1 pt-2 transition-colors hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-gray-700">
<div
onClick={() => setShowSendsModal(!showSendsModal)}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-md bg-neutral-100 px-1 pb-1 pt-2 transition-colors hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-gray-700",
{
"bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-gray-700":
showSendsModal,
},
)}
>
<div className="flex items-center gap-1">
<Icons.send className="size-3" />
<p className="line-clamp-1 text-start font-medium">
@@ -538,6 +550,16 @@ export default function EmailSidebar({
/>
)}
{showSendsModal && (
<Modal
className="md:max-w-2xl"
showModal={showSendsModal}
setShowModal={setShowSendsModal}
>
<SendsEmailList isAdminModel={isAdminModel} />
</Modal>
)}
{/* 创建\编辑邮箱的 Modal */}
{showEmailModal && (
<Modal showModal={showEmailModal} setShowModal={setShowEmailModal}>
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useCallback, useState } from "react";
import { UserSendEmail } from "@prisma/client";
import useSWR from "swr";
import { cn, fetcher, formatDate, htmlToText } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { PaginationWrapper } from "../shared/pagination";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
export default function SendsEmailList({
isAdminModel,
}: {
isAdminModel: boolean;
}) {
const pageSize = 10;
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
const { data, isLoading, error } = useSWR<{
list: UserSendEmail[];
total: number;
}>(
`/api/email/send/list?page=${currentPage}&size=${pageSize}&search=${encodeURIComponent(searchQuery)}&all=${isAdminModel}`,
fetcher,
{ dedupingInterval: 5000 },
);
const totalPages = data ? Math.ceil(data.total / pageSize) : 1;
const debouncedSearch = useCallback((value: string) => {
setSearchQuery(value);
setCurrentPage(1); // Reset to first page on search
}, []);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
debouncedSearch(e.target.value);
};
return (
<Card className="mx-auto w-full max-w-4xl">
<CardHeader>
<CardTitle>Sent Emails</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-2 flex items-center justify-between gap-4">
<Input
placeholder="Search by send to email..."
value={searchQuery}
onChange={handleSearch}
className="max-w-sm"
/>
</div>
{isLoading ? (
<div className="space-y-1">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
) : error ? (
<div className="text-center text-red-500">
Failed to load emails. Please try again.
</div>
) : !data || data.list.length === 0 ? (
<div className="text-center text-muted-foreground">
No emails found.
</div>
) : (
<div className="scrollbar-hidden max-h-[50vh] overflow-y-auto">
<div className="space-y-1">
{data.list.map((email) => (
<Collapsible
className="w-full rounded-lg border bg-white p-2 transition-all duration-200 hover:bg-gray-50"
key={email.id}
>
<CollapsibleTrigger className="w-full">
<div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
<div className="text-start">
<div className="truncate text-xs font-semibold text-neutral-600 dark:text-neutral-200">
<strong>From:</strong> {email.from}
</div>
<div className="truncate text-xs font-semibold text-neutral-600 dark:text-neutral-200">
<strong>To:</strong> {email.to}
</div>
<div className="text-xs text-neutral-600 dark:text-neutral-400">
<strong>Date:</strong>{" "}
{formatDate(email.createdAt as any)}
</div>
</div>
<div className="text-start">
<p className="line-clamp-1 truncate text-sm font-semibold text-neutral-600 dark:text-neutral-400">
{email.subject || "No subject"}
</p>
<p className="line-clamp-2 break-all text-xs text-neutral-500">
{htmlToText(email.html || "")}
</p>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 animate-fade-in break-all text-sm text-neutral-500">
{htmlToText(email.html || "")}
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
{data && totalPages > 1 && (
<PaginationWrapper
className="m-0 mt-6 scale-75 justify-center"
total={totalPages}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
/>
)}
</div>
)}
</CardContent>
</Card>
);
}
+44
View File
@@ -594,3 +594,47 @@ export async function getUserSendEmailCount(userId: string, admin: boolean) {
}
return prisma.userSendEmail.count({ where: { userId } });
}
export async function getUserSendEmailList(
userId: string,
admin: boolean,
page: number,
size: number,
search: string,
) {
const select = {
from: true,
to: true,
subject: true,
html: true,
createdAt: true,
};
let where: any = {};
if (admin) {
where = {
to: { contains: search, mode: "insensitive" },
};
} else {
where = {
userId,
to: { contains: search, mode: "insensitive" },
};
}
const listPromise = prisma.userSendEmail.findMany({
where,
select,
skip: (page - 1) * size,
take: size,
orderBy: {
updatedAt: "desc",
},
});
const totalPromise = prisma.userSendEmail.count({
where,
});
const [list, total] = await Promise.all([listPromise, totalPromise]);
return { list, total };
}
@@ -277,4 +277,19 @@ CREATE TABLE "user_send_emails"
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
);
CREATE INDEX "user_send_emails_userId_idx" ON "user_send_emails" ("userId");
CREATE INDEX "user_send_emails_userId_idx" ON "user_send_emails" ("userId");
-- CreateIndex
CREATE INDEX "users_createdAt_idx" ON "users"("created_at");
-- CreateIndex
CREATE INDEX "user_records_created_on_idx" ON "user_records"("created_on");
-- CreateIndex
CREATE INDEX "user_urls_createdAt_idx" ON "user_urls"("created_at");
-- CreateIndex
CREATE INDEX "user_emails_createdAt_idx" ON "user_emails"("createdAt");
-- CreateIndex
CREATE INDEX "forward_emails_createdAt_idx" ON "forward_emails"("createdAt");
+6 -2
View File
@@ -70,6 +70,7 @@ model User {
UserEmail UserEmail[]
UserSendEmail UserSendEmail[]
@@index([createdAt])
@@map(name: "users")
}
@@ -102,7 +103,7 @@ model UserRecord {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, created_on])
@@map(name: "user_records")
}
@@ -123,7 +124,7 @@ model UserUrl {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
UrlMeta UrlMeta[]
@@index([userId])
@@index([userId, createdAt])
@@map(name: "user_urls")
}
@@ -205,6 +206,7 @@ model ForwardEmail {
UserEmail UserEmail? @relation(fields: [to], references: [emailAddress])
@@index([createdAt])
@@map(name: "forward_emails")
}
@@ -221,6 +223,7 @@ model UserEmail {
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@map("user_emails")
}
@@ -241,5 +244,6 @@ model UserSendEmail {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([createdAt])
@@map("user_send_emails")
}
+101 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long