enhance list pagenation layout
This commit is contained in:
@@ -9,6 +9,7 @@ import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { DomainFormData } from "@/lib/dto/domains";
|
||||
import { fetcher, timeAgo } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -68,6 +69,7 @@ function TableColumnSekleton() {
|
||||
}
|
||||
|
||||
export default function DomainList({ user, action }: DomainListProps) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditDomain, setCurrentEditDomain] =
|
||||
@@ -326,6 +328,7 @@ export default function DomainList({ user, action }: DomainListProps) {
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ScrapeMeta } from "@prisma/client";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { useElementSize } from "@/hooks/use-element-size";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -54,6 +55,7 @@ export function LineChartMultiple({
|
||||
type1,
|
||||
type2,
|
||||
}: LineChartMultipleProps) {
|
||||
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
|
||||
const processedData = processChartData(chartData, type1, type2);
|
||||
|
||||
const chartConfig = {
|
||||
@@ -75,12 +77,13 @@ export function LineChartMultiple({
|
||||
{type2 && ` and ${type2}`}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent ref={wrapperRef}>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
className="mt-6"
|
||||
accessibilityLayer
|
||||
data={processedData}
|
||||
width={wrapperWidth}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
|
||||
@@ -73,6 +73,7 @@ function TableColumnSekleton({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
export default function UsersList({ user }: UrlListProps) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [currentEditUser, setcurrentEditUser] = useState<User | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -282,6 +283,7 @@ export default function UsersList({ user }: UrlListProps) {
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
|
||||
@@ -10,6 +10,7 @@ import useSWR, { useSWRConfig } from "swr";
|
||||
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { TTL_ENUMS } from "@/lib/enums";
|
||||
import { fetcher, timeAgo } from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -79,6 +80,7 @@ function TableColumnSekleton() {
|
||||
}
|
||||
|
||||
export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditRecord, setCurrentEditRecord] =
|
||||
@@ -328,6 +330,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
|
||||
@@ -98,12 +98,12 @@ const LogsTable = ({ userId, target }) => {
|
||||
onChange={(e) => handleFilterChange("type", e.target.value)}
|
||||
className="h-8 max-w-xs placeholder:text-xs"
|
||||
/>
|
||||
<Input
|
||||
{/* <Input
|
||||
placeholder="Filter by IP..."
|
||||
value={filters.ip}
|
||||
onChange={(e) => handleFilterChange("ip", e.target.value)}
|
||||
className="h-8 max-w-xs placeholder:text-xs"
|
||||
/>
|
||||
/> */}
|
||||
{
|
||||
<>
|
||||
<Input
|
||||
@@ -139,16 +139,15 @@ const LogsTable = ({ userId, target }) => {
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
<TableRow className="">
|
||||
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-6">
|
||||
<TableHead className="hidden items-center justify-start px-2 sm:flex">
|
||||
Date
|
||||
</TableHead>
|
||||
<TableHead className="px-2">Type</TableHead>
|
||||
<TableHead className="hidden items-center justify-start px-2 sm:flex">
|
||||
IP
|
||||
<TableHead className="flex items-center px-2">Type</TableHead>
|
||||
<TableHead className="col-span-3 flex items-center px-2">
|
||||
Link
|
||||
</TableHead>
|
||||
<TableHead className="px-2">Link</TableHead>
|
||||
<TableHead className="px-2">User</TableHead>
|
||||
<TableHead className="flex items-center px-2">User</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -161,9 +160,6 @@ const LogsTable = ({ userId, target }) => {
|
||||
<TableCell>
|
||||
<Skeleton className="h-2 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:inline-block">
|
||||
<Skeleton className="h-2 w-[120px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-2 w-[200px]" />
|
||||
</TableCell>
|
||||
@@ -173,15 +169,15 @@ const LogsTable = ({ userId, target }) => {
|
||||
</TableRow>
|
||||
))
|
||||
: logs.map((log) => (
|
||||
<TableRow className="text-xs hover:bg-muted" key={log.id}>
|
||||
<TableRow
|
||||
className="grid grid-cols-5 items-center text-xs hover:bg-muted sm:grid-cols-6"
|
||||
key={log.id}
|
||||
>
|
||||
<TableCell className="hidden truncate p-2 sm:inline-block">
|
||||
{new Date(log.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="p-2">{log.type}</TableCell>
|
||||
<TableCell className="hidden p-2 sm:inline-block">
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md truncate p-2">
|
||||
<TableCell className="col-span-3 max-w-full truncate p-2">
|
||||
{log.link}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md truncate p-2">
|
||||
@@ -193,12 +189,13 @@ const LogsTable = ({ userId, target }) => {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-start justify-between gap-2 sm:items-center">
|
||||
<p className="ml-auto text-nowrap text-sm">
|
||||
{nFormatter(data?.total || 0)} logs
|
||||
</p>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
className="m-0"
|
||||
total={data.total}
|
||||
currentPage={page}
|
||||
setCurrentPage={setPage}
|
||||
|
||||
@@ -200,19 +200,30 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
|
||||
{error ? (
|
||||
<div className="text-center text-red-500">{error.message}</div>
|
||||
) : logs.length === 0 && !newLogs ? (
|
||||
// <Skeleton className="h-8 w-full" />
|
||||
<></>
|
||||
) : (
|
||||
<div className="scrollbar-hidden h-96 overflow-y-auto bg-primary-foreground">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-100/50 text-sm dark:bg-primary-foreground">
|
||||
<TableHead className="h-8 w-1/6 px-1">Time</TableHead>
|
||||
<TableHead className="h-8 w-1/12 px-1">Slug</TableHead>
|
||||
<TableHead className="h-8 px-1">Target</TableHead>
|
||||
<TableHead className="h-8 w-1/12 px-1">IP</TableHead>
|
||||
<TableHead className="h-8 w-1/6 px-1">Location</TableHead>
|
||||
<TableHead className="h-8 w-1/12 px-1">Clicks</TableHead>
|
||||
<TableRow className="grid grid-cols-5 bg-gray-100/50 text-sm dark:bg-primary-foreground sm:grid-cols-9">
|
||||
<TableHead className="col-span-2 flex h-8 items-center">
|
||||
Time
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex h-8 items-center">
|
||||
Slug
|
||||
</TableHead>
|
||||
<TableHead className="col-span-3 hidden h-8 items-center sm:flex">
|
||||
Target
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden h-8 items-center sm:flex">
|
||||
IP
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex h-8 items-center">
|
||||
Location
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex h-8 items-center">
|
||||
Clicks
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -234,23 +245,24 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
|
||||
ease: "linear",
|
||||
},
|
||||
}}
|
||||
className="font-mono text-xs hover:bg-gray-200 dark:border-gray-800"
|
||||
className="grid grid-cols-5 font-mono text-xs hover:bg-gray-200 dark:border-gray-800 sm:grid-cols-9"
|
||||
>
|
||||
<TableCell className="whitespace-nowrap px-1 py-1.5">
|
||||
<TableCell className="col-span-2 truncate py-1.5">
|
||||
{new Date(log.updatedAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-midium px-1 py-1.5 text-green-700">
|
||||
<TableCell className="font-midium col-span-1 truncate py-1.5 text-green-700">
|
||||
{log.slug}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-10 truncate px-1 py-1.5 hover:underline">
|
||||
<TableCell className="col-span-3 hidden max-w-full truncate py-1.5 hover:underline sm:flex">
|
||||
<a href={log.target} target="_blank" title={log.target}>
|
||||
{log.target}
|
||||
</a>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="px-1 py-1.5">{log.ip}</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate py-1.5 sm:flex">
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-6 truncate px-1 py-1.5"
|
||||
className="col-span-1 truncate py-1.5"
|
||||
title={getCountryName(log.country || "")}
|
||||
>
|
||||
{decodeURIComponent(
|
||||
@@ -259,7 +271,7 @@ export default function LiveLog({ admin = false }: { admin?: boolean }) {
|
||||
: "-",
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-1 py-1.5 text-green-700">
|
||||
<TableCell className="col-span-1 py-1.5 text-green-700">
|
||||
{log.click}
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
|
||||
@@ -10,9 +10,17 @@ import { WorldMapTopoJSON } from "@unovis/ts/maps";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
import { TeamPlanQuota } from "@/config/team";
|
||||
import { getCountryName, getDeviceVendor } from "@/lib/contries";
|
||||
import {
|
||||
getBotName,
|
||||
getCountryName,
|
||||
getDeviceVendor,
|
||||
getEngineName,
|
||||
getLanguageName,
|
||||
getRegionName,
|
||||
} from "@/lib/contries";
|
||||
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
|
||||
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
|
||||
import { useElementSize } from "@/hooks/use-element-size";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -34,6 +42,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
const chartConfig = {
|
||||
@@ -117,7 +126,15 @@ function generateStatsList(
|
||||
? getCountryName(rawValue as string) // 国家代码转为国家名称
|
||||
: dimension === "device"
|
||||
? getDeviceVendor(rawValue as string) // 设备型号转为厂商名称
|
||||
: rawValue; // 其他维度直接使用原始值
|
||||
: dimension === "engine"
|
||||
? getEngineName(rawValue as string) // 引擎名称
|
||||
: dimension === "region"
|
||||
? getRegionName(rawValue as string) // 区域名称
|
||||
: dimension === "lang"
|
||||
? getLanguageName(rawValue as string) // 语言名称
|
||||
: dimension === "isBot"
|
||||
? getBotName(rawValue as boolean) // 是否为机器人
|
||||
: rawValue; // 其他维度直接使用原始值
|
||||
|
||||
const click = record.click || 0; // 确保 click 是数字,默认 0 如果未定义
|
||||
|
||||
@@ -154,6 +171,7 @@ export function DailyPVUVChart({
|
||||
setTimeRange: React.Dispatch<React.SetStateAction<string>>;
|
||||
user: Pick<User, "id" | "name" | "team">;
|
||||
}) {
|
||||
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
|
||||
const [activeChart, setActiveChart] =
|
||||
React.useState<keyof typeof chartConfig>("pv");
|
||||
|
||||
@@ -162,7 +180,6 @@ export function DailyPVUVChart({
|
||||
pv: entry.clicks,
|
||||
uv: new Set(entry.ips).size,
|
||||
}));
|
||||
// .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
const dataTotal = calculateUVAndPV(data);
|
||||
|
||||
@@ -214,9 +231,15 @@ export function DailyPVUVChart({
|
||||
const deviceStats = generateStatsList(data, "device");
|
||||
const browserStats = generateStatsList(data, "browser");
|
||||
const countryStats = generateStatsList(data, "country");
|
||||
const osStats = generateStatsList(data, "os");
|
||||
const cpuStats = generateStatsList(data, "cpu");
|
||||
const engineStats = generateStatsList(data, "engine");
|
||||
const languageStats = generateStatsList(data, "lang");
|
||||
const regionStats = generateStatsList(data, "region");
|
||||
const isBotStats = generateStatsList(data, "isBot");
|
||||
|
||||
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>Link Analytics</CardTitle>
|
||||
@@ -279,7 +302,7 @@ export function DailyPVUVChart({
|
||||
})}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<CardContent className="px-2 sm:p-6" ref={wrapperRef}>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[225px] w-full"
|
||||
@@ -351,9 +374,6 @@ export function DailyPVUVChart({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* <Bar dataKey="uv" fill={`var(--color-uv)`} stackId="a" />
|
||||
<Bar dataKey="pv" fill={`var(--color-pv)`} stackId="a" /> */}
|
||||
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="uv"
|
||||
@@ -371,31 +391,103 @@ export function DailyPVUVChart({
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
|
||||
<VisSingleContainer data={{ areas: areaData }}>
|
||||
<VisTopoJSONMap
|
||||
topojson={WorldMapTopoJSON}
|
||||
// pointRadius={1.6}
|
||||
// mapFitToPoints={true}
|
||||
/>
|
||||
<VisSingleContainer
|
||||
data={{ areas: areaData }}
|
||||
width={wrapperWidth * 0.99}
|
||||
>
|
||||
<VisTopoJSONMap topojson={WorldMapTopoJSON} />
|
||||
<VisTooltip triggers={triggers} />
|
||||
</VisSingleContainer>
|
||||
|
||||
<div className="my-5 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{refererStats.length > 0 && (
|
||||
<StatsList data={refererStats} title="Referrers" />
|
||||
)}
|
||||
{countryStats.length > 0 && (
|
||||
<StatsList data={countryStats} title="Countries" />
|
||||
)}
|
||||
{cityStats.length > 0 && (
|
||||
<StatsList data={cityStats} title="Cities" />
|
||||
)}
|
||||
{browserStats.length > 0 && (
|
||||
<StatsList data={browserStats} title="Browsers" />
|
||||
)}
|
||||
{deviceStats.length > 0 && (
|
||||
<StatsList data={deviceStats} title="Devices" />
|
||||
)}
|
||||
{/* Referrers、isBotStats */}
|
||||
<Tabs defaultValue="referrer">
|
||||
<TabsList>
|
||||
<TabsTrigger value="referrer">Referrers</TabsTrigger>
|
||||
<TabsTrigger value="isBot">Traffic Type</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[90%]" value="referrer">
|
||||
{refererStats.length > 0 && (
|
||||
<StatsList data={refererStats} title="Referrers" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[90%]" value="isBot">
|
||||
{isBotStats.length > 0 && (
|
||||
<StatsList data={isBotStats} title="Is Bot" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* 国家、城市 */}
|
||||
<Tabs defaultValue="country">
|
||||
<TabsList>
|
||||
<TabsTrigger value="country">Country</TabsTrigger>
|
||||
<TabsTrigger value="city">City</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[90%]" value="country">
|
||||
{countryStats.length > 0 && (
|
||||
<StatsList data={countryStats} title="Countries" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[90%]" value="city">
|
||||
{cityStats.length > 0 && (
|
||||
<StatsList data={cityStats} title="Cities" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* browserStats、engineStats */}
|
||||
<Tabs defaultValue="browser">
|
||||
<TabsList>
|
||||
<TabsTrigger value="browser">Browser</TabsTrigger>
|
||||
<TabsTrigger value="engine">Browser Engine</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[90%]" value="browser">
|
||||
{browserStats.length > 0 && (
|
||||
<StatsList data={browserStats} title="Browsers" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[90%]" value="engine">
|
||||
{engineStats.length > 0 && (
|
||||
<StatsList data={engineStats} title="Engines" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Languages、regionStats */}
|
||||
<Tabs className="h-full" defaultValue="language">
|
||||
<TabsList>
|
||||
<TabsTrigger value="language">Language</TabsTrigger>
|
||||
<TabsTrigger value="region">Region</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[90%]" value="language">
|
||||
{languageStats.length > 0 && (
|
||||
<StatsList data={languageStats} title="Languages" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[90%]" value="region">
|
||||
{regionStats.length > 0 && (
|
||||
<StatsList data={regionStats} title="Regions" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* deviceStats、osStats、cpuStats */}
|
||||
<Tabs defaultValue="device">
|
||||
<TabsList>
|
||||
<TabsTrigger value="device">Device</TabsTrigger>
|
||||
<TabsTrigger value="os">OS</TabsTrigger>
|
||||
<TabsTrigger value="cpu">CPU</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent className="h-[90%]" value="device">
|
||||
{deviceStats.length > 0 && (
|
||||
<StatsList data={deviceStats} title="Devices" />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[90%]" value="os">
|
||||
{osStats.length > 0 && <StatsList data={osStats} title="OS" />}
|
||||
</TabsContent>
|
||||
<TabsContent className="h-[90%]" value="cpu">
|
||||
{cpuStats.length > 0 && <StatsList data={cpuStats} title="CPU" />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -407,10 +499,13 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
|
||||
const displayedData = showAll ? data.slice(0, 50) : data.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h1 className="text-lg font-bold">{title}</h1>
|
||||
<div className="h-full rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b px-5 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>名称</span>
|
||||
<span className="">点击量</span>
|
||||
</div>
|
||||
<div
|
||||
className={`scrollbar-hidden overflow-hidden overflow-y-auto transition-all duration-500 ease-in-out`}
|
||||
className={`scrollbar-hidden overflow-hidden overflow-y-auto px-4 pb-4 pt-2 transition-all duration-500 ease-in-out`}
|
||||
style={{
|
||||
maxHeight: "18rem", // 动态计算最大高度
|
||||
}}
|
||||
@@ -454,7 +549,7 @@ export function StatsList({ data, title }: { data: Stat[]; title: string }) {
|
||||
</div>
|
||||
|
||||
{data.length > 8 && (
|
||||
<div className="mt-3 text-center">
|
||||
<div className="mb-3 mt-1 text-center">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
removeUrlSuffix,
|
||||
timeAgo,
|
||||
} from "@/lib/utils";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -99,6 +100,7 @@ function TableColumnSekleton() {
|
||||
|
||||
export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
const pathname = usePathname();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [currentView, setCurrentView] = useState<string>("List");
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
@@ -446,6 +448,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
</TableBody>
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
@@ -628,6 +631,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
|
||||
{data && Math.ceil(data.total / pageSize) > 1 && (
|
||||
<PaginationWrapper
|
||||
layout={isMobile ? "right" : "split"}
|
||||
total={data.total}
|
||||
currentPage={currentPage}
|
||||
setCurrentPage={setCurrentPage}
|
||||
|
||||
@@ -7,6 +7,7 @@ import useSWR from "swr";
|
||||
|
||||
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
|
||||
import { cn, fetcher, nFormatter } from "@/lib/utils";
|
||||
import { useElementSize } from "@/hooks/use-element-size";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -62,6 +63,7 @@ const chartConfig = {
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function InteractiveBarChart() {
|
||||
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
|
||||
const [timeRange, setTimeRange] = useState<string>("7d");
|
||||
const [activeChart, setActiveChart] =
|
||||
React.useState<keyof typeof chartConfig>("users");
|
||||
@@ -134,7 +136,7 @@ export function InteractiveBarChart() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6">
|
||||
{["users", "records", "urls", "emails", "inbox", "sends"].map(
|
||||
(key) => {
|
||||
const chart = key as keyof typeof chartConfig;
|
||||
@@ -144,13 +146,13 @@ export function InteractiveBarChart() {
|
||||
<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"
|
||||
className="relative z-30 flex flex-col justify-center gap-1 border-l border-t p-3 text-left transition-colors hover:bg-muted/30 data-[active=true]:bg-muted/50 sm:border-t-0 sm:p-4"
|
||||
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">
|
||||
<span className="text-base font-bold leading-none sm:text-lg">
|
||||
{nFormatter(data.total[key])}
|
||||
</span>
|
||||
<span
|
||||
@@ -170,7 +172,7 @@ export function InteractiveBarChart() {
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<CardContent ref={wrapperRef} className="px-2 sm:p-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[200px] w-full"
|
||||
@@ -178,6 +180,7 @@ export function InteractiveBarChart() {
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={data.list}
|
||||
width={wrapperWidth}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
|
||||
@@ -10,7 +10,7 @@ interface UseElementSizeOptions {
|
||||
box?: "content-box" | "border-box" | "device-pixel-content-box";
|
||||
}
|
||||
|
||||
export function useElementSize<T extends HTMLElement>(
|
||||
export function useElementSize<T extends HTMLDivElement>(
|
||||
initialSize: ElementSize = { width: 0, height: 0 },
|
||||
options: UseElementSizeOptions = {},
|
||||
): { ref: React.RefObject<T>; width: number; height: number } {
|
||||
|
||||
327
lib/contries.ts
327
lib/contries.ts
@@ -263,3 +263,330 @@ export const getDeviceVendor = (model: string) => {
|
||||
}
|
||||
return model;
|
||||
};
|
||||
|
||||
export function getRegionName(regionCode: string) {
|
||||
const regionMap = {
|
||||
// Vercel/Cloudflare 地区代码
|
||||
hkg1: "Hong Kong",
|
||||
sin1: "Singapore",
|
||||
iad1: "Washington D.C. (US East)",
|
||||
gru1: "São Paulo (Brazil)",
|
||||
sfo1: "San Francisco (US West)",
|
||||
cdg1: "Paris (France)",
|
||||
cle1: "Cleveland (US Central)",
|
||||
cpt1: "Cape Town (South Africa)",
|
||||
|
||||
// AWS 地区代码
|
||||
"us-east-1": "N. Virginia (US East)",
|
||||
"us-west-1": "N. California (US West)",
|
||||
"us-west-2": "Oregon (US West)",
|
||||
"eu-west-1": "Ireland (Europe)",
|
||||
"eu-central-1": "Frankfurt (Europe)",
|
||||
"ap-southeast-1": "Singapore (Asia Pacific)",
|
||||
"ap-northeast-1": "Tokyo (Asia Pacific)",
|
||||
"ap-south-1": "Mumbai (Asia Pacific)",
|
||||
|
||||
// Cloudflare 地区代码
|
||||
lhr: "London (UK)",
|
||||
fra: "Frankfurt (Germany)",
|
||||
ams: "Amsterdam (Netherlands)",
|
||||
nrt: "Tokyo (Japan)",
|
||||
icn: "Seoul (South Korea)",
|
||||
syd: "Sydney (Australia)",
|
||||
yyz: "Toronto (Canada)",
|
||||
mia: "Miami (US Southeast)",
|
||||
lax: "Los Angeles (US West)",
|
||||
ord: "Chicago (US Central)",
|
||||
atl: "Atlanta (US Southeast)",
|
||||
dfw: "Dallas (US Central)",
|
||||
sea: "Seattle (US West)",
|
||||
bos: "Boston (US Northeast)",
|
||||
ewr: "Newark (US Northeast)",
|
||||
jfk: "New York (US Northeast)",
|
||||
|
||||
// 其他常见地区代码
|
||||
pdx1: "Portland (US West)",
|
||||
bom1: "Mumbai (India)",
|
||||
syd1: "Sydney (Australia)",
|
||||
nrt1: "Tokyo (Japan)",
|
||||
fra1: "Frankfurt (Germany)",
|
||||
lon1: "London (UK)",
|
||||
ams1: "Amsterdam (Netherlands)",
|
||||
tor1: "Toronto (Canada)",
|
||||
nyc1: "New York (US East)",
|
||||
dub1: "Dublin (Ireland)",
|
||||
blr1: "Bangalore (India)",
|
||||
sgp1: "Singapore",
|
||||
hnd1: "Tokyo Haneda (Japan)",
|
||||
kix1: "Osaka (Japan)",
|
||||
icn1: "Seoul (South Korea)",
|
||||
bkk1: "Bangkok (Thailand)",
|
||||
mnl1: "Manila (Philippines)",
|
||||
jkt1: "Jakarta (Indonesia)",
|
||||
mel1: "Melbourne (Australia)",
|
||||
per1: "Perth (Australia)",
|
||||
akl1: "Auckland (New Zealand)",
|
||||
mad1: "Madrid (Spain)",
|
||||
bcn1: "Barcelona (Spain)",
|
||||
mxp1: "Milan (Italy)",
|
||||
vie1: "Vienna (Austria)",
|
||||
zrh1: "Zurich (Switzerland)",
|
||||
sto1: "Stockholm (Sweden)",
|
||||
hel1: "Helsinki (Finland)",
|
||||
cph1: "Copenhagen (Denmark)",
|
||||
osl1: "Oslo (Norway)",
|
||||
waw1: "Warsaw (Poland)",
|
||||
prg1: "Prague (Czech Republic)",
|
||||
bud1: "Budapest (Hungary)",
|
||||
buh1: "Bucharest (Romania)",
|
||||
sof1: "Sofia (Bulgaria)",
|
||||
ath1: "Athens (Greece)",
|
||||
ist1: "Istanbul (Turkey)",
|
||||
tlv1: "Tel Aviv (Israel)",
|
||||
cai1: "Cairo (Egypt)",
|
||||
jnb1: "Johannesburg (South Africa)",
|
||||
lag1: "Lagos (Nigeria)",
|
||||
nbo1: "Nairobi (Kenya)",
|
||||
dxb1: "Dubai (UAE)",
|
||||
bah1: "Bahrain",
|
||||
khi1: "Karachi (Pakistan)",
|
||||
del1: "Delhi (India)",
|
||||
ccj1: "Kolkata (India)",
|
||||
maa1: "Chennai (India)",
|
||||
hyd1: "Hyderabad (India)",
|
||||
pnq1: "Pune (India)",
|
||||
};
|
||||
|
||||
return regionMap[regionCode.toLowerCase()] || regionCode.toUpperCase();
|
||||
}
|
||||
|
||||
export function getLanguageName(langCode: string) {
|
||||
// 统一转换为小写处理大小写不一致
|
||||
const normalizedCode = langCode.toLowerCase();
|
||||
|
||||
const languageMap = {
|
||||
// 英语系列
|
||||
en: "English",
|
||||
"en-us": "English (United States)",
|
||||
"en-gb": "English (United Kingdom)",
|
||||
"en-ca": "English (Canada)",
|
||||
"en-au": "English (Australia)",
|
||||
"en-nz": "English (New Zealand)",
|
||||
"en-ie": "English (Ireland)",
|
||||
"en-za": "English (South Africa)",
|
||||
"en-in": "English (India)",
|
||||
|
||||
// 中文系列
|
||||
zh: "Chinese",
|
||||
"zh-cn": "Chinese (Simplified)",
|
||||
"zh-tw": "Chinese (Traditional)",
|
||||
"zh-hk": "Chinese (Hong Kong)",
|
||||
"zh-sg": "Chinese (Singapore)",
|
||||
|
||||
// 法语系列
|
||||
fr: "French",
|
||||
"fr-fr": "French (France)",
|
||||
"fr-ca": "French (Canada)",
|
||||
"fr-be": "French (Belgium)",
|
||||
"fr-ch": "French (Switzerland)",
|
||||
|
||||
// 德语系列
|
||||
de: "German",
|
||||
"de-de": "German (Germany)",
|
||||
"de-at": "German (Austria)",
|
||||
"de-ch": "German (Switzerland)",
|
||||
|
||||
// 西班牙语系列
|
||||
es: "Spanish",
|
||||
"es-es": "Spanish (Spain)",
|
||||
"es-mx": "Spanish (Mexico)",
|
||||
"es-ar": "Spanish (Argentina)",
|
||||
"es-co": "Spanish (Colombia)",
|
||||
"es-cl": "Spanish (Chile)",
|
||||
|
||||
// 葡萄牙语系列
|
||||
pt: "Portuguese",
|
||||
"pt-pt": "Portuguese (Portugal)",
|
||||
"pt-br": "Portuguese (Brazil)",
|
||||
|
||||
// 意大利语
|
||||
it: "Italian",
|
||||
"it-it": "Italian (Italy)",
|
||||
|
||||
// 俄语
|
||||
ru: "Russian",
|
||||
"ru-ru": "Russian (Russia)",
|
||||
|
||||
// 日语
|
||||
ja: "Japanese",
|
||||
"ja-jp": "Japanese (Japan)",
|
||||
|
||||
// 韩语
|
||||
ko: "Korean",
|
||||
"ko-kr": "Korean (South Korea)",
|
||||
|
||||
// 阿拉伯语
|
||||
ar: "Arabic",
|
||||
"ar-sa": "Arabic (Saudi Arabia)",
|
||||
"ar-ae": "Arabic (UAE)",
|
||||
"ar-eg": "Arabic (Egypt)",
|
||||
|
||||
// 荷兰语
|
||||
nl: "Dutch",
|
||||
"nl-nl": "Dutch (Netherlands)",
|
||||
"nl-be": "Dutch (Belgium)",
|
||||
|
||||
// 北欧语言
|
||||
sv: "Swedish",
|
||||
"sv-se": "Swedish (Sweden)",
|
||||
da: "Danish",
|
||||
"da-dk": "Danish (Denmark)",
|
||||
no: "Norwegian",
|
||||
"no-no": "Norwegian (Norway)",
|
||||
"nb-no": "Norwegian Bokmål",
|
||||
"nn-no": "Norwegian Nynorsk",
|
||||
fi: "Finnish",
|
||||
"fi-fi": "Finnish (Finland)",
|
||||
|
||||
// 其他常见语言
|
||||
hi: "Hindi",
|
||||
"hi-in": "Hindi (India)",
|
||||
th: "Thai",
|
||||
"th-th": "Thai (Thailand)",
|
||||
vi: "Vietnamese",
|
||||
"vi-vn": "Vietnamese (Vietnam)",
|
||||
tr: "Turkish",
|
||||
"tr-tr": "Turkish (Turkey)",
|
||||
pl: "Polish",
|
||||
"pl-pl": "Polish (Poland)",
|
||||
cs: "Czech",
|
||||
"cs-cz": "Czech (Czech Republic)",
|
||||
sk: "Slovak",
|
||||
"sk-sk": "Slovak (Slovakia)",
|
||||
hu: "Hungarian",
|
||||
"hu-hu": "Hungarian (Hungary)",
|
||||
ro: "Romanian",
|
||||
"ro-ro": "Romanian (Romania)",
|
||||
bg: "Bulgarian",
|
||||
"bg-bg": "Bulgarian (Bulgaria)",
|
||||
hr: "Croatian",
|
||||
"hr-hr": "Croatian (Croatia)",
|
||||
sr: "Serbian",
|
||||
"sr-rs": "Serbian (Serbia)",
|
||||
sl: "Slovenian",
|
||||
"sl-si": "Slovenian (Slovenia)",
|
||||
et: "Estonian",
|
||||
"et-ee": "Estonian (Estonia)",
|
||||
lv: "Latvian",
|
||||
"lv-lv": "Latvian (Latvia)",
|
||||
lt: "Lithuanian",
|
||||
"lt-lt": "Lithuanian (Lithuania)",
|
||||
el: "Greek",
|
||||
"el-gr": "Greek (Greece)",
|
||||
he: "Hebrew",
|
||||
"he-il": "Hebrew (Israel)",
|
||||
fa: "Persian",
|
||||
"fa-ir": "Persian (Iran)",
|
||||
ur: "Urdu",
|
||||
"ur-pk": "Urdu (Pakistan)",
|
||||
bn: "Bengali",
|
||||
"bn-bd": "Bengali (Bangladesh)",
|
||||
ta: "Tamil",
|
||||
"ta-in": "Tamil (India)",
|
||||
te: "Telugu",
|
||||
"te-in": "Telugu (India)",
|
||||
ml: "Malayalam",
|
||||
"ml-in": "Malayalam (India)",
|
||||
kn: "Kannada",
|
||||
"kn-in": "Kannada (India)",
|
||||
gu: "Gujarati",
|
||||
"gu-in": "Gujarati (India)",
|
||||
pa: "Punjabi",
|
||||
"pa-in": "Punjabi (India)",
|
||||
mr: "Marathi",
|
||||
"mr-in": "Marathi (India)",
|
||||
ne: "Nepali",
|
||||
"ne-np": "Nepali (Nepal)",
|
||||
si: "Sinhala",
|
||||
"si-lk": "Sinhala (Sri Lanka)",
|
||||
my: "Myanmar",
|
||||
"my-mm": "Myanmar (Myanmar)",
|
||||
km: "Khmer",
|
||||
"km-kh": "Khmer (Cambodia)",
|
||||
lo: "Lao",
|
||||
"lo-la": "Lao (Laos)",
|
||||
ka: "Georgian",
|
||||
"ka-ge": "Georgian (Georgia)",
|
||||
hy: "Armenian",
|
||||
"hy-am": "Armenian (Armenia)",
|
||||
az: "Azerbaijani",
|
||||
"az-az": "Azerbaijani (Azerbaijan)",
|
||||
kk: "Kazakh",
|
||||
"kk-kz": "Kazakh (Kazakhstan)",
|
||||
ky: "Kyrgyz",
|
||||
"ky-kg": "Kyrgyz (Kyrgyzstan)",
|
||||
uz: "Uzbek",
|
||||
"uz-uz": "Uzbek (Uzbekistan)",
|
||||
tg: "Tajik",
|
||||
"tg-tj": "Tajik (Tajikistan)",
|
||||
mn: "Mongolian",
|
||||
"mn-mn": "Mongolian (Mongolia)",
|
||||
bo: "Tibetan",
|
||||
"bo-cn": "Tibetan (China)",
|
||||
ug: "Uyghur",
|
||||
"ug-cn": "Uyghur (China)",
|
||||
id: "Indonesian",
|
||||
"id-id": "Indonesian (Indonesia)",
|
||||
ms: "Malay",
|
||||
"ms-my": "Malay (Malaysia)",
|
||||
tl: "Filipino",
|
||||
"tl-ph": "Filipino (Philippines)",
|
||||
sw: "Swahili",
|
||||
"sw-ke": "Swahili (Kenya)",
|
||||
am: "Amharic",
|
||||
"am-et": "Amharic (Ethiopia)",
|
||||
ha: "Hausa",
|
||||
"ha-ng": "Hausa (Nigeria)",
|
||||
yo: "Yoruba",
|
||||
"yo-ng": "Yoruba (Nigeria)",
|
||||
ig: "Igbo",
|
||||
"ig-ng": "Igbo (Nigeria)",
|
||||
zu: "Zulu",
|
||||
"zu-za": "Zulu (South Africa)",
|
||||
xh: "Xhosa",
|
||||
"xh-za": "Xhosa (South Africa)",
|
||||
af: "Afrikaans",
|
||||
"af-za": "Afrikaans (South Africa)",
|
||||
};
|
||||
|
||||
// 如果找到精确匹配,返回对应值
|
||||
if (languageMap[normalizedCode]) {
|
||||
return languageMap[normalizedCode];
|
||||
}
|
||||
|
||||
// 如果没有精确匹配,尝试匹配语言部分(如 en-xx -> English)
|
||||
const langPart = normalizedCode.split("-")[0];
|
||||
if (languageMap[langPart]) {
|
||||
return languageMap[langPart];
|
||||
}
|
||||
|
||||
// 如果都没有匹配,返回原始值(大写)
|
||||
return langCode.toUpperCase();
|
||||
}
|
||||
|
||||
export function getEngineName(engine: string) {
|
||||
const engineMap = {
|
||||
Blink: "Chrome Engine",
|
||||
WebKit: "Safari Engine",
|
||||
Gecko: "Firefox Engine",
|
||||
Trident: "IE Engine",
|
||||
EdgeHTML: "Edge Engine",
|
||||
Presto: "Opera Engine",
|
||||
};
|
||||
|
||||
return engineMap[engine] || `${engine} Engine`;
|
||||
}
|
||||
|
||||
export function getBotName(bot: boolean) {
|
||||
return bot === true ? "Bot" : "Human";
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user