diff --git a/src/actions/resources.ts b/src/actions/resources.ts index 1eaccad..dfa034a 100644 --- a/src/actions/resources.ts +++ b/src/actions/resources.ts @@ -28,7 +28,11 @@ export async function listResourceShort(params: ResourceListParams) { ) } -export async function updateResource(data: { id: number; active?: boolean; checkip?: boolean}) { +export async function updateResource(data: { + id: number + active?: boolean + checkip?: boolean +}) { return callByUser("/api/admin/resource/update", data) } diff --git a/src/app/(root)/(dashboard)/page.tsx b/src/app/(root)/(dashboard)/page.tsx index 9ca7586..2752ab5 100644 --- a/src/app/(root)/(dashboard)/page.tsx +++ b/src/app/(root)/(dashboard)/page.tsx @@ -1,515 +1,439 @@ +"use client" + +import { format, subDays } from "date-fns" +import { + Activity, + ArrowDownRight, + ArrowUpRight, + BarChart3, + Calendar, + DoorOpenIcon, + Server, + TrendingUp, + UserPlus, + Users, +} from "lucide-react" import Link from "next/link" +import { useMemo, useState } from "react" +import { + SimpleBarChart, + SimpleHorizontalBarChart, + SimpleLineChart, +} from "@/components/charts" +import { Page } from "@/components/page" -export type DashboardPageProps = {} +type TimeRange = "today" | "7d" | "30d" | "90d" | "custom" + +const timeRangeLabels: Record = { + today: "今日", + "7d": "近7天", + "30d": "近30天", + "90d": "近90天", + custom: "自定义", +} + +function mulberry32(seed: number) { + let t = seed + return () => { + t |= 0 + t = (t + 0x6d2b79f5) | 0 + let n = Math.imul(t ^ (t >>> 15), 1 | t) + n = (n + Math.imul(n ^ (n >>> 7), 61 | n)) ^ n + return ((n ^ (n >>> 14)) >>> 0) / 4294967296 + } +} + +export default function DashboardPage() { + const [timeRange, setTimeRange] = useState("7d") + const [showCustomPicker, setShowCustomPicker] = useState(false) + const [startDate, setStartDate] = useState("") + const [endDate, setEndDate] = useState("") + + const chartData = useMemo(() => { + const days = 14 + const seed = 42 + const rng = mulberry32(seed) + const proxyData = [] + const tradeData = [] + const customerData = [] + const extractData = [] + + for (let i = days - 1; i >= 0; i--) { + const date = subDays(new Date(), i) + const dateStr = format(date, "MM-dd") + proxyData.push({ + date: dateStr, + value: Math.floor(rng() * 50) + 80, + }) + tradeData.push({ + date: dateStr, + value: Math.floor(rng() * 3000) + 5000, + }) + customerData.push({ + date: dateStr, + value: Math.floor(rng() * 20) + 5, + }) + extractData.push({ + date: dateStr, + value: Math.floor(rng() * 800) + 1200, + }) + } + + const userData = [ + { name: "张三", activity: 287 }, + { name: "李四", activity: 245 }, + { name: "王五", activity: 198 }, + { name: "赵六", activity: 156 }, + { name: "孙七", activity: 132 }, + { name: "周八", activity: 98 }, + { name: "吴九", activity: 76 }, + ] + + return { proxyData, tradeData, customerData, extractData, userData } + }, []) + + // 处理自定义日期按钮点击 + const handleCustomClick = () => { + setShowCustomPicker(true) + setTimeRange("custom") + // 清空之前的日期 + setStartDate("") + setEndDate("") + } + + // 处理日期确认 + const handleDateConfirm = () => { + if (startDate && endDate) { + console.log("自定义日期范围:", startDate, "~", endDate) + setShowCustomPicker(false) + } + } + + // 处理取消 + const handleCancel = () => { + setShowCustomPicker(false) + setTimeRange("7d") + } -export default function DashboardPage(props: DashboardPageProps) { return ( -
- {/* 欢迎区域 - 全宽 */} -
-
-
-

- IP代理管理控制台 -

-

上次更新: -

-
-
- - -
+ + {/* 欢迎栏 */} +
+
+

IP代理管理控制台

+

上次更新: --

+
+
+ + + 客户管理 + + + + 交易明细 + + + + 网关列表 +
- {/* 主体内容 - 双栏布局 */} -
- {/* 左侧栏 - 占比较大 */} -
- {/* 代理资源统计卡片组 */} -
- - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> -
+
+ {/* 指标卡片 */} +
+
+
+ + + + + +
- {/* 代理使用图表 */} -
-
-

代理使用趋势

-
- -
-
-
- 请求量与响应时间统计图表 -
-
-
- - 请求数量 -
-
- - 成功率 -
-
- - 响应时间 -
-
-
- - {/* IP代理列表 */} -
-
-
-

活跃代理IP

- - 查看全部 - -
-
-
- - - - - - - - - - - - - {[1, 2, 3, 4, 5].map(item => ( - + {/* 时间筛选栏 */} +
+
+ 数据周期 +
+
+ {(Object.keys(timeRangeLabels) as TimeRange[]).map(key => ( + ))} -
-
IP地址位置状态请求次数成功率操作
+
+
+ + {/* 点击自定义时显示 */} + {showCustomPicker && ( +
+ setStartDate(e.target.value)} + /> + ~ + setEndDate(e.target.value)} + /> + + +
+ )}
-
- - +
+ + {/* 图表区域 */} +
+ {/* 每日活跃代理 */} +
+

每日活跃代理

+ +
+ + {/* 每日交易额 */} +
+

每日交易额

+ +
+ + {/* 每日新增客户 */} +
+

每日新增客户

+ +
+ + {/* 每日提取量 */} +
+

每日提取量

+
- {/* 右侧栏 - 占比较小 */} -
- {/* 代理资源分布 */} -
-
-

资源分布

+ {/*套餐统计 */} +
+
+
+

套餐统计

-
- IP地区分布饼图 -
-
-
- 中国 - 42% -
-
- 美国 - 28% -
-
- 欧洲 - 16% -
-
- 其他 - 14% +
+
+ + +
+ 总可用IP + -- +
- {/* 系统告警 */} -
-
-
-

告警通知

- - 3 个新告警 - -
+ {/* 最近活跃用户 */} +
+
+

最近活跃用户

-
- + - - -
-
- -
-
- - {/* 系统状态 */} -
-
-
-

系统状态

- - 运行正常 - -
-
-
- - - - -
-
- - 查看详细状态 -
-
+ ) } -// 数据卡片组件 - 显示关键指标 -function DataCard({ +function StatCard({ title, value, change, - isIncrease, - icon, + trend, + icon: Icon, + iconColor, + iconBg, }: { title: string value: string change: string - isIncrease: boolean - icon: React.ReactNode + trend: "up" | "down" + icon: React.ComponentType<{ className?: string }> + iconColor: string + iconBg: string }) { return ( -
-
-
-

{title}

-

{value}

-
+
+
+
+

{title}

+

{value}

+
+ {trend === "up" ? ( + + ) : ( + + )} {change} - 相比上周期 + 较昨日
-
- {icon} +
+
) } -// 代理IP内容行组件 -function ContentRow({ - ip, - location, - status, - requests, - successRate, +function ResourceItem({ + label, + active, + total, + color, }: { - ip: string - location: string - status: "active" | "warning" | "error" - requests: number - successRate: string + label: string + active: string + total: string + color: string }) { - const statusConfig = { - active: { - color: "bg-green-100 text-green-800 border-green-200", - label: "在线", - }, - warning: { - color: "bg-yellow-100 text-yellow-800 border-yellow-200", - label: "不稳定", - }, - error: { color: "bg-red-100 text-red-800 border-red-200", label: "离线" }, - } - return ( - - -
{ip}
- - -
{location}
- - - - {statusConfig[status].label} +
+
+ {label} + + {active} / {total} - - - {requests.toLocaleString()} - - {successRate} - -
- - - -
- - - ) -} - -// 告警通知组件 -function AlertItem({ - title, - severity, - time, - message, -}: { - title: string - severity: "high" | "medium" | "low" - time: string - message: string -}) { - const severityConfig = { - high: { color: "bg-red-50 border-red-200", dot: "bg-red-500" }, - medium: { color: "bg-yellow-50 border-yellow-200", dot: "bg-yellow-500" }, - low: { color: "bg-blue-50 border-blue-200", dot: "bg-blue-500" }, - } - - return ( -
-
-
- - {title} -
- {time}
-

{message}

-
- ) -} - -// 系统状态条组件 -function StatusBar({ - title, - value, - status, -}: { - title: string - value: number - status: "normal" | "warning" | "error" -}) { - const statusConfig = { - normal: { color: "bg-green-500", bgColor: "bg-green-100" }, - warning: { color: "bg-yellow-500", bgColor: "bg-yellow-100" }, - error: { color: "bg-red-500", bgColor: "bg-red-100" }, - } - - return ( -
-
- {title} - {value}% -
-
+
+ className={`h-full ${color} rounded-full transition-all`} + style={{ width: "0%" }} + />
) -} +} \ No newline at end of file diff --git a/src/app/(root)/_navigation/index.tsx b/src/app/(root)/_navigation/index.tsx index eedf1b7..3d4221b 100644 --- a/src/app/(root)/_navigation/index.tsx +++ b/src/app/(root)/_navigation/index.tsx @@ -164,7 +164,7 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [ title: "概览", items: [ { href: "/", icon: Home, label: "首页" }, - { href: "/statistics", icon: BarChart3, label: "数据统计" }, + // { href: "/statistics", icon: BarChart3, label: "数据统计" }, ], }, { diff --git a/src/app/(root)/balance/page.tsx b/src/app/(root)/balance/page.tsx index 31a3005..afe4d3a 100644 --- a/src/app/(root)/balance/page.tsx +++ b/src/app/(root)/balance/page.tsx @@ -238,13 +238,13 @@ export default function BalancePage() { header: "创建时间", accessorKey: "created_at", cell: ({ row }) => { - const createdAt = row.original.created_at; - if (!createdAt) return -; - - const date = new Date(createdAt); - if (isNaN(date.getTime())) return -; - - return format(date, "yyyy-MM-dd HH:mm:ss"); + const createdAt = row.original.created_at + if (!createdAt) return - + + const date = new Date(createdAt) + if (isNaN(date.getTime())) return - + + return format(date, "yyyy-MM-dd HH:mm:ss") }, }, ]} diff --git a/src/app/(root)/coupon/create.tsx b/src/app/(root)/coupon/create.tsx index b5d36ff..4d3cd0f 100644 --- a/src/app/(root)/coupon/create.tsx +++ b/src/app/(root)/coupon/create.tsx @@ -25,42 +25,47 @@ import { SelectValue, } from "@/components/ui/select" -const schema = z.object({ - name: z.string().min(1, "请输入优惠券名称"), - amount: z.string() - .min(1, "请输入优惠券金额") - .regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字") - .refine(val => Number(val) > 0, "优惠券金额必须大于0"), - count: z.string() - .min(1, "请输入优惠券数量") - .regex(/^\d+$/, "请输入正整数") - .refine(val => Number(val) >= 1, "优惠券数量至少为1"), - min_amount: z.string() - .min(1, "请输入最低消费金额") - .regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字") - .refine(val => Number(val) >= 0, "最低消费金额不能为负数"), - expire_at: z.string().optional(), - expire_type: z.string().min(1, "请选择过期类型"), - expire_in: z.string().optional(), - status:z.string().optional() -}).superRefine((data, ctx) => { - const expireType = Number(data.expire_type); - if (!data.expire_type) return; - if (expireType === 1 && !data.expire_at) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "请选择过期时间", - path: ["expire_at"] - }); - } - if (expireType === 2 && !data.expire_in) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "请输入过期时长天数", - path: ["expire_in"] - }); - } -}) +const schema = z + .object({ + name: z.string().min(1, "请输入优惠券名称"), + amount: z + .string() + .min(1, "请输入优惠券金额") + .regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字") + .refine(val => Number(val) > 0, "优惠券金额必须大于0"), + count: z + .string() + .min(1, "请输入优惠券数量") + .regex(/^\d+$/, "请输入正整数") + .refine(val => Number(val) >= 1, "优惠券数量至少为1"), + min_amount: z + .string() + .min(1, "请输入最低消费金额") + .regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字") + .refine(val => Number(val) >= 0, "最低消费金额不能为负数"), + expire_at: z.string().optional(), + expire_type: z.string().min(1, "请选择过期类型"), + expire_in: z.string().optional(), + status: z.string().optional(), + }) + .superRefine((data, ctx) => { + const expireType = Number(data.expire_type) + if (!data.expire_type) return + if (expireType === 1 && !data.expire_at) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "请选择过期时间", + path: ["expire_at"], + }) + } + if (expireType === 2 && !data.expire_in) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "请输入过期时长天数", + path: ["expire_in"], + }) + } + }) export function CreateDiscount(props: { onSuccess?: () => void }) { const [open, setOpen] = useState(false) @@ -90,7 +95,7 @@ export function CreateDiscount(props: { onSuccess?: () => void }) { name: data.name, amount: Number(data.amount), count: Number(data?.count), - status:Number(data.status), + status: Number(data.status), min_amount: Number(data?.min_amount), expire_at: data?.expire_at ? new Date(data.expire_at) : undefined, expire_in: expireType === 2 ? Number(data.expire_in) : undefined, @@ -232,7 +237,7 @@ export function CreateDiscount(props: { onSuccess?: () => void }) {
)} /> - {watchExpireType === "1" && ( + {watchExpireType === "1" && ( void }) { name="expire_in" render={({ field, fieldState }) => (
- 过期时长(天): + + 过期时长(天): +
- {fieldState.error?.message}
diff --git a/src/app/(root)/coupon/update.tsx b/src/app/(root)/coupon/update.tsx index 794268e..8b4cf82 100644 --- a/src/app/(root)/coupon/update.tsx +++ b/src/app/(root)/coupon/update.tsx @@ -25,42 +25,47 @@ import { } from "@/components/ui/select" import type { Coupon } from "@/models/coupon" -const schema = z.object({ - name: z.string().min(1, "请输入优惠券名称"), - amount: z.string() - .min(1, "请输入优惠券金额") - .regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字") - .refine(val => Number(val) > 0, "优惠券金额必须大于0"), - count: z.string() - .min(1, "请输入优惠券数量") - .regex(/^\d+$/, "请输入正整数") - .refine(val => Number(val) >= 1, "优惠券数量至少为1"), - min_amount: z.string() - .min(1, "请输入最低消费金额") - .regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字") - .refine(val => Number(val) >= 0, "最低消费金额不能为负数"), - expire_at: z.string().optional(), - expire_type: z.string().min(1, "请选择过期类型"), - expire_in: z.string().optional(), - status:z.string().optional() -}).superRefine((data, ctx) => { - const expireType = Number(data.expire_type); - if (!data.expire_type) return; - if (expireType === 1 && !data.expire_at) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "请选择过期时间", - path: ["expire_at"] - }); - } - if (expireType === 2 && !data.expire_in) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "请输入过期时长天数", - path: ["expire_in"] - }); - } -}) +const schema = z + .object({ + name: z.string().min(1, "请输入优惠券名称"), + amount: z + .string() + .min(1, "请输入优惠券金额") + .regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字") + .refine(val => Number(val) > 0, "优惠券金额必须大于0"), + count: z + .string() + .min(1, "请输入优惠券数量") + .regex(/^\d+$/, "请输入正整数") + .refine(val => Number(val) >= 1, "优惠券数量至少为1"), + min_amount: z + .string() + .min(1, "请输入最低消费金额") + .regex(/^\d+(\.\d+)?$/, "请输入有效的金额数字") + .refine(val => Number(val) >= 0, "最低消费金额不能为负数"), + expire_at: z.string().optional(), + expire_type: z.string().min(1, "请选择过期类型"), + expire_in: z.string().optional(), + status: z.string().optional(), + }) + .superRefine((data, ctx) => { + const expireType = Number(data.expire_type) + if (!data.expire_type) return + if (expireType === 1 && !data.expire_at) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "请选择过期时间", + path: ["expire_at"], + }) + } + if (expireType === 2 && !data.expire_in) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "请输入过期时长天数", + path: ["expire_in"], + }) + } + }) export function UpdateCoupon(props: { coupon: Coupon @@ -88,7 +93,7 @@ export function UpdateCoupon(props: { const watchExpireType = watch("expire_type") const onSubmit = async (data: z.infer) => { try { - const expireType = Number(data.expire_type) + const expireType = Number(data.expire_type) const payload = { id: props.coupon.id, name: data.name, @@ -100,7 +105,7 @@ export function UpdateCoupon(props: { expire_at: data.expire_at ? new Date(data.expire_at) : undefined, expire_in: expireType === 2 ? Number(data.expire_in) : undefined, } - const resp = await updateCoupon(payload) + const resp = await updateCoupon(payload) if (resp.success) { toast.success("优惠券修改成功") props.onSuccess?.() @@ -119,7 +124,7 @@ export function UpdateCoupon(props: { reset({ name: props.coupon.name, count: String(props.coupon.count), - amount: String(props.coupon.amount ), + amount: String(props.coupon.amount), min_amount: String(props.coupon.min_amount), expire_at: props.coupon.expire_at ? new Date(props.coupon.expire_at).toISOString().split("T")[0] @@ -245,7 +250,7 @@ export function UpdateCoupon(props: {
)} /> - {watchExpireType === "1" && ( + {watchExpireType === "1" && ( (
- 过期时长(天): + + 过期时长(天): +
- {fieldState.error?.message}
diff --git a/src/components/charts/SimpleBarChart.tsx b/src/components/charts/SimpleBarChart.tsx new file mode 100644 index 0000000..76fd1c6 --- /dev/null +++ b/src/components/charts/SimpleBarChart.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useMemo, useState } from "react" + +interface SimpleBarChartProps { + data: Record[] + dataKey: string + xAxisKey: string + fill?: string + height?: number +} + +export function SimpleBarChart({ + data, + dataKey, + xAxisKey, + fill = "#f59e0b", + height = 250, +}: SimpleBarChartProps) { + const [tooltip, setTooltip] = useState<{ + x: number + y: number + label: string + value: number + } | null>(null) + + const padding = { top: 20, right: 20, bottom: 40, left: 50 } + const chartWidth = 600 + const chartHeight = height + const innerWidth = chartWidth - padding.left - padding.right + const innerHeight = chartHeight - padding.top - padding.bottom + + const { bars, xLabels, yLabels } = useMemo(() => { + if (data.length === 0) return { bars: [], xLabels: [], yLabels: [] } + + const values = data.map(d => Number(d[dataKey]) || 0) + const maxVal = Math.max(...values, 1) + const yMax = Math.ceil(maxVal * 1.2) + + const barCount = data.length + const barGap = barCount > 1 ? (innerWidth * 0.1) / (barCount - 1) : 0 + const totalGap = barGap * (barCount - 1) + const barWidth = Math.min((innerWidth - totalGap) / barCount, 50) + + const startX = + padding.left + (innerWidth - (barWidth * barCount + totalGap)) / 2 + + const brs = data.map((d, i) => { + const val = Number(d[dataKey]) || 0 + const barHeight = (val / yMax) * innerHeight + return { + x: startX + i * (barWidth + barGap), + y: padding.top + innerHeight - barHeight, + width: barWidth, + height: barHeight, + label: String(d[xAxisKey] ?? ""), + value: val, + } + }) + + const xLbls = brs.map(b => ({ + x: b.x + b.width / 2, + label: b.label, + })) + + const yTickCount = 5 + const yLbls = Array.from({ length: yTickCount + 1 }, (_, i) => { + const val = Math.round((yMax / yTickCount) * i) + return { + y: padding.top + innerHeight - (val / yMax) * innerHeight, + label: String(val), + } + }) + + return { bars: brs, xLabels: xLbls, yLabels: yLbls } + }, [data, dataKey, xAxisKey, innerWidth, innerHeight]) + + if (data.length === 0) { + return ( +
+ 暂无数据 +
+ ) + } + + return ( +
+ + {yLabels.map(yl => ( + + + + {yl.label} + + + ))} + + {bars.map((bar, i) => ( + + setTooltip({ + x: bar.x + bar.width / 2, + y: bar.y, + label: bar.label, + value: bar.value, + }) + } + onMouseLeave={() => setTooltip(null)} + /> + ))} + + {xLabels.map((xl, i) => + i % Math.ceil(xLabels.length / 10) === 0 || + i === xLabels.length - 1 ? ( + + {xl.label} + + ) : null, + )} + + + {tooltip && ( +
+
{tooltip.label}
+
{tooltip.value}
+
+ )} +
+ ) +} diff --git a/src/components/charts/SimpleHorizontalBarChart.tsx b/src/components/charts/SimpleHorizontalBarChart.tsx new file mode 100644 index 0000000..a351c5f --- /dev/null +++ b/src/components/charts/SimpleHorizontalBarChart.tsx @@ -0,0 +1,101 @@ +"use client" + +import { useMemo } from "react" + +interface SimpleHorizontalBarChartProps { + data: Record[] + dataKey: string + labelKey: string + fill?: string + width?: number +} + +export function SimpleHorizontalBarChart({ + data, + dataKey, + labelKey, + fill = "#6366f1", + width = 300, +}: SimpleHorizontalBarChartProps) { + const chartWidth = width + const chartHeight = Math.max(data.length * 40 + 20, 120) + const padding = { top: 10, right: 10, bottom: 10, left: 80 } + const innerWidth = chartWidth - padding.left - padding.right + + const bars = useMemo(() => { + if (data.length === 0) return [] + + const values = data.map(d => Number(d[dataKey]) || 0) + const maxVal = Math.max(...values, 1) + + return data.map((d, i) => { + const val = Number(d[dataKey]) || 0 + const barWidth = (val / maxVal) * innerWidth * 0.9 + const y = + padding.top + + (i * (chartHeight - padding.top - padding.bottom)) / data.length + return { + x: padding.left, + y, + width: barWidth, + height: Math.min( + 24, + (chartHeight - padding.top - padding.bottom) / data.length - 4, + ), + label: String(d[labelKey] ?? ""), + value: val, + } + }) + }, [data, dataKey, labelKey, innerWidth, chartHeight]) + + if (data.length === 0) { + return ( +
+ 暂无数据 +
+ ) + } + + return ( + + {bars.map((bar, i) => ( + + + {bar.label.length > 6 ? `${bar.label.slice(0, 6)}...` : bar.label} + + + + {bar.value} + + + ))} + + ) +} diff --git a/src/components/charts/SimpleLineChart.tsx b/src/components/charts/SimpleLineChart.tsx new file mode 100644 index 0000000..586a660 --- /dev/null +++ b/src/components/charts/SimpleLineChart.tsx @@ -0,0 +1,193 @@ +"use client" + +import { useMemo, useState } from "react" + +interface SimpleLineChartProps { + data: Record[] + dataKey: string + xAxisKey: string + stroke?: string + height?: number +} + +export function SimpleLineChart({ + data, + dataKey, + xAxisKey, + stroke = "#3b82f6", + height = 250, +}: SimpleLineChartProps) { + const [tooltip, setTooltip] = useState<{ + x: number + y: number + label: string + value: number + } | null>(null) + + const padding = { top: 20, right: 20, bottom: 40, left: 50 } + const chartWidth = 600 + const chartHeight = height + const innerWidth = chartWidth - padding.left - padding.right + const innerHeight = chartHeight - padding.top - padding.bottom + + const { points, xLabels, yLabels } = useMemo(() => { + if (data.length === 0) return { points: [], xLabels: [], yLabels: [] } + + const values = data.map(d => Number(d[dataKey]) || 0) + const maxVal = Math.max(...values, 1) + const yMax = Math.ceil(maxVal * 1.2) + + const step = innerWidth / Math.max(data.length - 1, 1) + + const pts = data.map((d, i) => ({ + x: padding.left + i * step, + y: padding.top + innerHeight - (Number(d[dataKey]) / yMax) * innerHeight, + })) + + const xLbls = data.map((d, i) => ({ + x: padding.left + i * step, + label: String(d[xAxisKey] ?? ""), + })) + + const yTickCount = 5 + const yLbls = Array.from({ length: yTickCount + 1 }, (_, i) => { + const val = Math.round((yMax / yTickCount) * i) + return { + y: padding.top + innerHeight - (val / yMax) * innerHeight, + label: String(val), + } + }) + + return { points: pts, xLabels: xLbls, yLabels: yLbls } + }, [data, dataKey, xAxisKey, innerWidth, innerHeight]) + + if (data.length === 0) { + return ( +
+ 暂无数据 +
+ ) + } + + const linePath = points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`) + .join(" ") + const areaPath = + points.length > 0 + ? `${linePath} L ${points[points.length - 1].x} ${padding.top + innerHeight} L ${points[0].x} ${padding.top + innerHeight} Z` + : "" + + return ( +
+ + + + + + + + + {yLabels.map(yl => ( + + + + {yl.label} + + + ))} + + + + + + {points.map((p, i) => ( + + setTooltip({ + x: p.x, + y: p.y, + label: String(data[i]?.[xAxisKey] ?? ""), + value: Number(data[i]?.[dataKey]) || 0, + }) + } + onMouseLeave={() => setTooltip(null)} + /> + ))} + + {xLabels.map((xl, i) => + i % Math.ceil(xLabels.length / 7) === 0 || + i === xLabels.length - 1 ? ( + + {xl.label} + + ) : null, + )} + + + {tooltip && ( +
+
{tooltip.label}
+
{tooltip.value}
+
+ )} +
+ ) +} diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts new file mode 100644 index 0000000..4ea2e74 --- /dev/null +++ b/src/components/charts/index.ts @@ -0,0 +1,3 @@ +export { SimpleBarChart } from "./SimpleBarChart" +export { SimpleHorizontalBarChart } from "./SimpleHorizontalBarChart" +export { SimpleLineChart } from "./SimpleLineChart" diff --git a/src/models/resources.ts b/src/models/resources.ts index e0485bb..c3456a8 100644 --- a/src/models/resources.ts +++ b/src/models/resources.ts @@ -10,7 +10,7 @@ type ResourceBase = { updated_at: Date deleted_at: Date | null user: User - checkip:boolean + checkip: boolean } type ResourceShort = {