diff --git a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx index 0d67f4b42..5692d53ff 100644 --- a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx +++ b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx @@ -27,6 +27,21 @@ const USER_COLOR_PALETTE = [ const getUserColor = (index: number) => USER_COLOR_PALETTE[index % USER_COLOR_PALETTE.length]; +const CHART_HEIGHT_MAX_PX_WITH_LEGEND = 240; +const CHART_HEIGHT_MAX_PX_NO_LEGEND = 280; + +function parsePx(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed === "0") return 0; + + // 避免误把 50vh/50dvh 之类的相对单位当作 px(会导致高度计算错误)。 + if (!trimmed.endsWith("px")) return null; + + const parsed = Number.parseFloat(trimmed.slice(0, -2)); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + export interface StatisticsChartCardProps { data: UserStatisticsData; onTimeRangeChange?: (timeRange: TimeRange) => void; @@ -58,6 +73,133 @@ export function StatisticsChartCard({ const isAdminMode = data.mode === "users"; const enableUserFilter = isAdminMode && data.users.length > 1; + const cardRef = React.useRef(null); + const headerRef = React.useRef(null); + const metricTabsRef = React.useRef(null); + const chartWrapperRef = React.useRef(null); + const legendRef = React.useRef(null); + const tooltipScrollRef = React.useRef(null); + + const [chartHeightPx, setChartHeightPx] = React.useState(() => + enableUserFilter ? CHART_HEIGHT_MAX_PX_WITH_LEGEND : CHART_HEIGHT_MAX_PX_NO_LEGEND + ); + + React.useLayoutEffect(() => { + const card = cardRef.current; + const header = headerRef.current; + const metricTabs = metricTabsRef.current; + const chartWrapper = chartWrapperRef.current; + + if (!card || !header || !metricTabs || !chartWrapper) { + return; + } + + const compute = () => { + const cardStyle = getComputedStyle(card); + const viewportHeightPx = window.visualViewport?.height ?? window.innerHeight; + const fallbackMaxHeightPx = Math.min(Math.floor(viewportHeightPx * 0.6), 720); + const maxHeightPx = parsePx(cardStyle.maxHeight) ?? fallbackMaxHeightPx; + const minHeightPx = parsePx(cardStyle.minHeight) ?? 0; + const effectiveMaxHeightPx = Math.max(maxHeightPx, minHeightPx); + + const cardPadding = + (parsePx(cardStyle.paddingTop) ?? 0) + (parsePx(cardStyle.paddingBottom) ?? 0); + const cardBorder = + (parsePx(cardStyle.borderTopWidth) ?? 0) + (parsePx(cardStyle.borderBottomWidth) ?? 0); + + const headerHeight = header.getBoundingClientRect().height; + const metricTabsHeight = metricTabs.getBoundingClientRect().height; + const legendHeight = enableUserFilter + ? (legendRef.current?.getBoundingClientRect().height ?? 0) + : 0; + + const chartWrapperStyle = getComputedStyle(chartWrapper); + const chartWrapperPadding = + (parsePx(chartWrapperStyle.paddingTop) ?? 0) + + (parsePx(chartWrapperStyle.paddingBottom) ?? 0); + + const reservedHeight = + cardPadding + headerHeight + metricTabsHeight + legendHeight + chartWrapperPadding; + + // 避免 1px 级的裁切: + // - DOM 计算高度可能带小数(浏览器缩放 / 子像素) + // - 卡片自身 border 也会占用 max-h 的可用空间 + const reservedHeightRounded = Math.ceil(reservedHeight); + // 额外留 4px 兜底空间,覆盖不同浏览器/缩放下的 rounding、边框与子像素误差,避免底部内容露半截。 + const safetyGapPx = 4; + const availableHeight = Math.max( + 0, + Math.floor(effectiveMaxHeightPx - reservedHeightRounded - cardBorder - safetyGapPx) + ); + + const maxChartHeight = enableUserFilter + ? CHART_HEIGHT_MAX_PX_WITH_LEGEND + : CHART_HEIGHT_MAX_PX_NO_LEGEND; + + const nextHeight = Math.max(0, Math.min(maxChartHeight, availableHeight)); + setChartHeightPx((prev) => (prev === nextHeight ? prev : nextHeight)); + }; + + compute(); + + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", compute); + return () => window.removeEventListener("resize", compute); + } + + const visualViewport = window.visualViewport; + // 即使有 ResizeObserver,也需要监听 viewport 变化: + // - 当内容高度小于 max-h 时,卡片本身不会因为视口变高而触发 RO + // - 但 vh/dvh 上限变大后,需要让图表高度回弹 + // 同时监听 visualViewport resize,覆盖移动端地址栏/键盘导致的可视区域变化。 + visualViewport?.addEventListener("resize", compute); + + const observer = new ResizeObserver(compute); + observer.observe(header); + observer.observe(metricTabs); + if (enableUserFilter && legendRef.current) { + observer.observe(legendRef.current); + } + window.addEventListener("resize", compute); + + return () => { + window.removeEventListener("resize", compute); + visualViewport?.removeEventListener("resize", compute); + observer.disconnect(); + }; + }, [enableUserFilter]); + + React.useEffect(() => { + const chartWrapper = chartWrapperRef.current; + if (!chartWrapper) return; + + const handleWheel = (event: WheelEvent) => { + const scrollContainer = tooltipScrollRef.current; + if (!scrollContainer) return; + + const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight; + if (maxScrollTop <= 0) return; + + const deltaY = + event.deltaMode === 1 + ? event.deltaY * 16 + : event.deltaMode === 2 + ? event.deltaY * 240 + : event.deltaY; + if (!Number.isFinite(deltaY) || deltaY === 0) return; + + const current = scrollContainer.scrollTop; + const next = Math.min(maxScrollTop, Math.max(0, current + deltaY)); + if (next === current) return; + + scrollContainer.scrollTop = next; + event.preventDefault(); + }; + + chartWrapper.addEventListener("wheel", handleWheel, { passive: false }); + return () => chartWrapper.removeEventListener("wheel", handleWheel); + }, []); + const toggleUserSelection = (userId: number) => { setSelectedUserIds((prev) => { const next = new Set(prev); @@ -167,16 +309,25 @@ export function StatisticsChartCard({ return formatInTimeZone(date, timeZone, "yyyy MMMM d", { locale: dateFnsLocale }); }; + // 图表卡片整体 max-h 为 min(60vh, 720px),用于保持首页更紧凑且避免大屏过高。 + // 同时设置 min-h 300px(优先级最高),保证最小可读性。 + // 关键目标:不让卡片本身滚动,在小视口下通过收缩图表高度,确保底部 Legend/按钮不被裁切。 + // 这里使用 DOM 实测(Header/MetricTabs/Legend/ChartPadding)来计算可用高度,避免硬编码误差。 + return ( {/* Header */} -
-
+
+

{t("title")}

{/* Chart Mode Toggle */} {visibleUsers.length > 1 && ( @@ -208,7 +359,7 @@ export function StatisticsChartCard({ data-active={data.timeRange === option.key} onClick={() => onTimeRangeChange(option.key)} className={cn( - "px-3 py-3 text-xs font-medium transition-colors cursor-pointer", + "px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer", "border-l border-border/50 dark:border-white/[0.06] first:border-l-0", "hover:bg-muted/50 dark:hover:bg-white/[0.03]", "data-[active=true]:bg-primary/10 data-[active=true]:text-primary" @@ -222,12 +373,12 @@ export function StatisticsChartCard({
{/* Metric Tabs */} -
+
@@ -244,7 +395,7 @@ export function StatisticsChartCard({ data-active={activeChart === "calls"} onClick={() => setActiveChart("calls")} className={cn( - "flex-1 flex flex-col items-start gap-0.5 px-4 py-3 transition-colors cursor-pointer", + "flex-1 flex flex-col items-start gap-0.5 px-4 py-2 transition-colors cursor-pointer", "hover:bg-muted/30 dark:hover:bg-white/[0.02]", "data-[active=true]:bg-muted/50 dark:data-[active=true]:bg-white/[0.04]" )} @@ -252,15 +403,19 @@ export function StatisticsChartCard({ {t("totalCalls")} - + {visibleTotals.calls.toLocaleString()}
{/* Chart */} -
- +
+ {data.users.map((user, index) => { @@ -307,6 +462,8 @@ export function StatisticsChartCard({ /> { if (!active || !payload?.length) return null; const filteredPayload = payload.filter((entry) => { @@ -317,45 +474,51 @@ export function StatisticsChartCard({ if (!filteredPayload.length) return null; return ( -
-
+
+
{formatTooltipDate(String(label ?? ""))}
-
- {[...filteredPayload] - .sort((a, b) => (Number(b.value ?? 0) || 0) - (Number(a.value ?? 0) || 0)) - .map((entry, index) => { - const baseKey = - entry.dataKey?.toString().replace(`_${activeChart}`, "") || ""; - const displayUser = userMap.get(baseKey); - const value = - typeof entry.value === "number" - ? entry.value - : Number(entry.value ?? 0); - return ( -
-
-
- - {displayUser?.name === "__others__" - ? t("othersAggregate") - : displayUser?.name || baseKey} +
+
+ {[...filteredPayload] + .sort((a, b) => (Number(b.value ?? 0) || 0) - (Number(a.value ?? 0) || 0)) + .map((entry, index) => { + const baseKey = + entry.dataKey?.toString().replace(`_${activeChart}`, "") || ""; + const displayUser = userMap.get(baseKey); + const value = + typeof entry.value === "number" + ? entry.value + : Number(entry.value ?? 0); + return ( +
+
+
+ + {displayUser?.name === "__others__" + ? t("othersAggregate") + : displayUser?.name || baseKey} + +
+ + {activeChart === "cost" + ? formatCurrency(value, currencyCode) + : value.toLocaleString()}
- - {activeChart === "cost" - ? formatCurrency(value, currencyCode) - : value.toLocaleString()} - -
- ); - })} + ); + })} +
); @@ -392,14 +555,14 @@ export function StatisticsChartCard({ {/* Legend */} {enableUserFilter && ( -
- {/* Control buttons */} -
+
+ {/* Control buttons (floating, does not take extra vertical space) */} +
- |
- {/* User list with max 3 rows and scroll - only show users with non-zero usage */} -
+ {/* User list with max 3 rows (3 * 24px = 72px) and scroll - only show users with non-zero usage */} +
{data.users .map((user, originalIndex) => ({ user, originalIndex })) @@ -453,7 +621,10 @@ export function StatisticsChartCard({ className="h-2 w-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} /> - + {user.name === "__others__" ? t("othersAggregate") : user.name} diff --git a/src/app/globals.css b/src/app/globals.css index 4bc2e3056..99515af83 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,6 +47,7 @@ --radius: 0.65rem; --cch-viewport-height: 100vh; --cch-viewport-height-50: 50vh; + --cch-viewport-height-60: 60vh; --cch-viewport-height-70: 70vh; --cch-viewport-height-80: 80vh; --cch-viewport-height-85: 85vh; @@ -89,6 +90,7 @@ :root { --cch-viewport-height: 100dvh; --cch-viewport-height-50: 50dvh; + --cch-viewport-height-60: 60dvh; --cch-viewport-height-70: 70dvh; --cch-viewport-height-80: 80dvh; --cch-viewport-height-85: 85dvh;