feat(api): add stats chart endpoint for admin access with time series data
feat(dayjs): integrate dayjs for date handling and formatting across the application refactor(routes): update date handling in id-tags, transactions, users, and dashboard routes to use dayjs style(globals): improve CSS variable definitions for better readability and consistency deps: add dayjs as a dependency for date manipulation
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
import { ArrowLeft, Pencil, PlugConnection, ArrowRotateRight } from "@gravity-ui/icons";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
|
||||
// ── Status maps ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -60,8 +61,7 @@ const TX_LIMIT = 10;
|
||||
|
||||
function formatDuration(start: string, stop: string | null): string {
|
||||
if (!stop) return "进行中";
|
||||
const ms = new Date(stop).getTime() - new Date(start).getTime();
|
||||
const min = Math.floor(ms / 60000);
|
||||
const min = dayjs(stop).diff(dayjs(start), "minute");
|
||||
if (min < 60) return `${min} 分钟`;
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
@@ -69,15 +69,7 @@ function formatDuration(start: string, stop: string | null): string {
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s} 秒前`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m} 分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} 小时前`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d} 天前`;
|
||||
return dayjs(iso).fromNow();
|
||||
}
|
||||
|
||||
// ── Edit form type ─────────────────────────────────────────────────────────
|
||||
@@ -155,7 +147,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
// Online if last heartbeat within 3× interval
|
||||
const isOnline =
|
||||
cp?.lastHeartbeatAt != null &&
|
||||
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < (cp.heartbeatInterval ?? 60) * 3 * 1000;
|
||||
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < (cp.heartbeatInterval ?? 60) * 3;
|
||||
|
||||
const { data: sessionData } = useSession();
|
||||
const isAdmin = sessionData?.user?.role === "admin";
|
||||
@@ -343,7 +335,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
<dt className="shrink-0 text-sm text-muted">最后心跳</dt>
|
||||
<dd className="text-right text-sm text-foreground">
|
||||
{cp.lastHeartbeatAt ? (
|
||||
<span title={new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")}>
|
||||
<span title={dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")}>
|
||||
{relativeTime(cp.lastHeartbeatAt)}
|
||||
</span>
|
||||
) : (
|
||||
@@ -355,7 +347,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
<dt className="shrink-0 text-sm text-muted">最后启动通知</dt>
|
||||
<dd className="text-right text-sm text-foreground">
|
||||
{cp.lastBootNotificationAt ? (
|
||||
<span title={new Date(cp.lastBootNotificationAt).toLocaleString("zh-CN")}>
|
||||
<span title={dayjs(cp.lastBootNotificationAt).format("YYYY/M/D HH:mm:ss")}>
|
||||
{relativeTime(cp.lastBootNotificationAt)}
|
||||
</span>
|
||||
) : (
|
||||
@@ -366,7 +358,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<dt className="shrink-0 text-sm text-muted">注册时间</dt>
|
||||
<dd className="text-sm text-foreground">
|
||||
{new Date(cp.createdAt).toLocaleDateString("zh-CN")}
|
||||
{dayjs(cp.createdAt).format("YYYY/M/D")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -437,12 +429,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
{conn.info && <p className="text-xs text-muted">{conn.info}</p>}
|
||||
<p className="text-xs text-muted">
|
||||
更新于{" "}
|
||||
{new Date(conn.lastStatusAt).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{dayjs(conn.lastStatusAt).format("MM/DD HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -489,12 +476,7 @@ export default function ChargePointDetailPage({ params }: { params: Promise<{ id
|
||||
</Table.Cell>
|
||||
<Table.Cell className="font-mono">{tx.idTag}</Table.Cell>
|
||||
<Table.Cell className="tabular-nums text-sm">
|
||||
{new Date(tx.startTimestamp).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{dayjs(tx.startTimestamp).format("MM/DD HH:mm")}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDuration(tx.startTimestamp, tx.stopTimestamp)}</Table.Cell>
|
||||
<Table.Cell className="tabular-nums">
|
||||
|
||||
@@ -27,6 +27,7 @@ import Link from "next/link";
|
||||
import { ScrollFade } from "@/components/scroll-fade";
|
||||
import { api, type ChargePoint } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
Available: "空闲中",
|
||||
@@ -473,7 +474,7 @@ export default function ChargePointsPage() {
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{cp.lastHeartbeatAt ? (
|
||||
new Date(cp.lastHeartbeatAt).toLocaleString("zh-CN")
|
||||
dayjs(cp.lastHeartbeatAt).format("YYYY/M/D HH:mm:ss")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Xmark,
|
||||
} from "@gravity-ui/icons";
|
||||
import { api } from "@/lib/api";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
|
||||
// ── Status maps (same as charge-points page) ────────────────────────────────
|
||||
|
||||
@@ -411,7 +412,7 @@ export default function ChargePage() {
|
||||
.map((cp) => {
|
||||
const online =
|
||||
!!cp.lastHeartbeatAt &&
|
||||
Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
|
||||
dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||
const availableCount = cp.connectors.filter(
|
||||
(c) => c.status === "Available",
|
||||
).length;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { parseDate } from "@internationalized/date";
|
||||
import { ArrowRotateRight, Pencil, Plus, TrashBin } from "@gravity-ui/icons";
|
||||
import { api, type IdTag, type UserRow } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
|
||||
const statusColorMap: Record<string, "success" | "danger" | "warning"> = {
|
||||
Accepted: "success",
|
||||
@@ -529,7 +530,7 @@ export default function IdTagsPage() {
|
||||
)}
|
||||
<Table.Cell>
|
||||
{tag.expiryDate ? (
|
||||
new Date(tag.expiryDate).toLocaleDateString("zh-CN")
|
||||
dayjs(tag.expiryDate).format("YYYY/M/D")
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
@@ -538,7 +539,7 @@ export default function IdTagsPage() {
|
||||
{tag.parentIdTag ?? <span className="text-muted">—</span>}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{new Date(tag.createdAt).toLocaleString("zh-CN")}
|
||||
{dayjs(tag.createdAt).format("YYYY/M/D HH:mm:ss")}
|
||||
</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Card, Spinner } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
@@ -13,23 +14,29 @@ import {
|
||||
ArrowRotateRight,
|
||||
TriangleExclamation,
|
||||
} from "@gravity-ui/icons";
|
||||
import { AreaChart } from "@tremor/react";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { api, type Stats, type UserStats, type Transaction, type ChargePoint } from "@/lib/api";
|
||||
import {
|
||||
api,
|
||||
type Stats,
|
||||
type UserStats,
|
||||
type Transaction,
|
||||
type ChargePoint,
|
||||
type ChartRange,
|
||||
type ChartDataPoint,
|
||||
} from "@/lib/api";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return "—";
|
||||
const ms = Date.now() - new Date(dateStr).getTime();
|
||||
if (ms < 60_000) return "刚刚";
|
||||
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} 分钟前`;
|
||||
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} 小时前`;
|
||||
return new Date(dateStr).toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
||||
return dayjs(dateStr).fromNow();
|
||||
}
|
||||
|
||||
function cpOnline(cp: ChargePoint): boolean {
|
||||
if (!cp.lastHeartbeatAt) return false;
|
||||
return Date.now() - new Date(cp.lastHeartbeatAt).getTime() < 120_000;
|
||||
return dayjs().diff(dayjs(cp.lastHeartbeatAt), "second") < 120;
|
||||
}
|
||||
|
||||
// ── StatCard ───────────────────────────────────────────────────────────────
|
||||
@@ -98,6 +105,188 @@ function Panel({ title, children }: { title: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
// ── TrendChart ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBucket(bucket: string, range: ChartRange): string {
|
||||
const d = dayjs(bucket);
|
||||
return range === "24h" ? d.format("HH:mm") : d.format("MM/DD");
|
||||
}
|
||||
|
||||
const RANGES: { label: string; value: ChartRange }[] = [
|
||||
{ label: "24 小时", value: "24h" },
|
||||
{ label: "7 天", value: "7d" },
|
||||
{ label: "30 天", value: "30d" },
|
||||
];
|
||||
|
||||
type ChartView = "economy" | "activity";
|
||||
|
||||
const SERIES_CONFIG = {
|
||||
revenue: {
|
||||
color: "sky" as const,
|
||||
label: "营收",
|
||||
unit: "¥",
|
||||
fmt: (v: number) => (v === 0 ? "¥0" : `¥${v.toFixed(v < 10 ? 2 : 1)}`),
|
||||
},
|
||||
energyKwh: {
|
||||
color: "emerald" as const,
|
||||
label: "充电量",
|
||||
unit: "kWh",
|
||||
fmt: (v: number) => (v === 0 ? "0 kWh" : `${v.toFixed(v < 10 ? 2 : 1)} kWh`),
|
||||
},
|
||||
transactions: {
|
||||
color: "violet" as const,
|
||||
label: "充电次数",
|
||||
unit: "次",
|
||||
fmt: (v: number) => `${v} 次`,
|
||||
},
|
||||
utilizationPct: {
|
||||
color: "amber" as const,
|
||||
label: "利用率",
|
||||
unit: "%",
|
||||
fmt: (v: number) => `${v.toFixed(1)}%`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const VIEW_SERIES: Record<ChartView, (keyof typeof SERIES_CONFIG)[]> = {
|
||||
economy: ["revenue", "energyKwh"],
|
||||
activity: ["transactions", "utilizationPct"],
|
||||
};
|
||||
|
||||
function TrendChart() {
|
||||
const [range, setRange] = useState<ChartRange>("7d");
|
||||
const [view, setView] = useState<ChartView>("economy");
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggle = (key: string) =>
|
||||
setHidden((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const switchView = (v: ChartView) => {
|
||||
setView(v);
|
||||
setHidden(new Set());
|
||||
};
|
||||
|
||||
const allSeriesForView = VIEW_SERIES[view];
|
||||
const visibleSeries = allSeriesForView.filter((k) => !hidden.has(k));
|
||||
const visibleColors = visibleSeries.map((k) => SERIES_CONFIG[k].color);
|
||||
|
||||
const { data, isPending } = useQuery<ChartDataPoint[]>({
|
||||
queryKey: ["stats-chart", range],
|
||||
queryFn: () => api.stats.chart(range),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-secondary overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-5 py-3.5">
|
||||
{/* View toggle */}
|
||||
<div className="flex gap-1">
|
||||
{(["economy", "activity"] as ChartView[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => switchView(v)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
view === v
|
||||
? "bg-accent-soft text-accent"
|
||||
: "text-muted hover:bg-default hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{v === "economy" ? "营收 & 电量" : "次数 & 利用率"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Range toggle */}
|
||||
<div className="flex gap-1">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
range === r.value
|
||||
? "bg-accent text-white"
|
||||
: "text-muted hover:bg-default hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="px-4 py-4">
|
||||
{isPending ? (
|
||||
<div className="flex h-56 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<AreaChart
|
||||
data={(data ?? []).map((p) => ({ ...p, label: formatBucket(p.bucket, range) }))}
|
||||
index="label"
|
||||
categories={visibleSeries}
|
||||
colors={visibleColors}
|
||||
valueFormatter={(v) => `${v}`}
|
||||
customTooltip={(props) => {
|
||||
if (!props.active || !props.payload?.length) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-overlay px-3 py-2 shadow-md text-xs">
|
||||
<p className="mb-1.5 font-semibold text-foreground">{props.label}</p>
|
||||
{props.payload.map((entry) => {
|
||||
const key = entry.dataKey as string;
|
||||
const cfg = SERIES_CONFIG[key as keyof typeof SERIES_CONFIG];
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
|
||||
<span className="text-muted">{cfg.label}</span>
|
||||
<span className="ml-auto font-medium text-foreground">
|
||||
{cfg.fmt(entry.value as number)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
showLegend={false}
|
||||
showGridLines={true}
|
||||
showYAxis={true}
|
||||
showXAxis={true}
|
||||
curveType="monotone"
|
||||
showAnimation
|
||||
className="h-56"
|
||||
/>
|
||||
)}
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex items-center gap-2 px-1">
|
||||
{allSeriesForView.map((key) => {
|
||||
const cfg = SERIES_CONFIG[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggle(key)}
|
||||
className={`flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs transition-opacity select-none hover:opacity-100 ${
|
||||
hidden.has(key) ? "opacity-35" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-2 w-2 rounded-full bg-${cfg.color}-500`} />
|
||||
<span className="text-muted">
|
||||
{cfg.label}({cfg.unit})
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── RecentTransactions ────────────────────────────────────────────────────
|
||||
|
||||
function RecentTransactions({ txns }: { txns: Transaction[] }) {
|
||||
@@ -369,6 +558,9 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trend chart */}
|
||||
{isAdmin && <TrendChart />}
|
||||
|
||||
{/* Detail panels */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||
<div className="lg:col-span-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Button, CloseButton, Input, Label, Spinner, TextField, toast } from "@heroui/react";
|
||||
import { Fingerprint, Lock, Pencil, Person, TrashBin, Xmark, Check } from "@gravity-ui/icons";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
|
||||
type Passkey = {
|
||||
id: string;
|
||||
@@ -411,11 +412,7 @@ export default function SettingsPage() {
|
||||
{/* Date row: always visible */}
|
||||
<p className="text-xs text-muted">
|
||||
添加于{" "}
|
||||
{new Date(pk.createdAt).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
{dayjs(pk.createdAt).format("YYYY年M月D日")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,13 @@ import { Button, Chip, Modal, Pagination, Spinner, Table } from "@heroui/react";
|
||||
import { TrashBin, ArrowRotateRight } from "@gravity-ui/icons";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
|
||||
const LIMIT = 15;
|
||||
|
||||
function formatDuration(start: string, stop: string | null): string {
|
||||
if (!stop) return "进行中";
|
||||
const ms = new Date(stop).getTime() - new Date(start).getTime();
|
||||
const min = Math.floor(ms / 60000);
|
||||
const min = dayjs(stop).diff(dayjs(start), "minute");
|
||||
if (min < 60) return `${min} 分钟`;
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
@@ -78,23 +78,32 @@ export default function TransactionsPage() {
|
||||
<p className="mt-0.5 text-sm text-muted">共 {data?.total ?? "—"} 条</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button isIconOnly size="sm" variant="ghost" isDisabled={refreshing} onPress={() => refetch()} aria-label="刷新">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={refreshing}
|
||||
onPress={() => refetch()}
|
||||
aria-label="刷新"
|
||||
>
|
||||
<ArrowRotateRight className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
<div className="flex gap-1.5 rounded-xl bg-surface-secondary p-1">
|
||||
{(["all", "active", "completed"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => handleStatusChange(s)}
|
||||
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
|
||||
status === s
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
|
||||
</button>
|
||||
))} </div> </div>
|
||||
{(["all", "active", "completed"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => handleStatusChange(s)}
|
||||
className={`rounded-lg px-3 py-1 text-sm font-medium transition-colors ${
|
||||
status === s
|
||||
? "bg-surface text-foreground shadow-sm"
|
||||
: "text-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "全部" : s === "active" ? "进行中" : "已完成"}
|
||||
</button>
|
||||
))}{" "}
|
||||
</div>{" "}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
@@ -138,7 +147,7 @@ export default function TransactionsPage() {
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="whitespace-nowrap text-sm">
|
||||
{new Date(tx.startTimestamp).toLocaleString("zh-CN")}
|
||||
{dayjs(tx.startTimestamp).format("YYYY/M/D HH:mm:ss")}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{formatDuration(tx.startTimestamp, tx.stopTimestamp)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { CreditCard, Pencil, ArrowRotateRight } from "@gravity-ui/icons";
|
||||
import { api, type IdTag, type UserRow } from "@/lib/api";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import dayjs from "@/lib/dayjs";
|
||||
|
||||
type CreateForm = {
|
||||
name: string;
|
||||
@@ -452,7 +453,7 @@ export default function UsersPage() {
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-sm">
|
||||
{new Date(u.createdAt).toLocaleString("zh-CN")}
|
||||
{dayjs(u.createdAt).format("YYYY/M/D HH:mm:ss")}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex justify-end gap-1">
|
||||
|
||||
Reference in New Issue
Block a user