feat(admin-web): make KOL dashboard trend chart responsive with tooltip
Chart now adapts to container size via useElementSize and adds a hover tooltip with per-point overlay. Prevents grid/flex blowout with min-width and layers the SVG absolutely inside its wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch, shallowRef } from "vue";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useElementSize } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserAddOutlined,
|
UserAddOutlined,
|
||||||
@@ -93,36 +94,81 @@ const packageColumns: TableColumnsType<KolDashboardPackageStat> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// SVG Chart logic
|
// SVG Chart logic
|
||||||
const chartViewBox = "0 0 1000 300";
|
const chartWrapperRef = ref<HTMLElement | null>(null);
|
||||||
const chartPadding = { top: 20, right: 40, bottom: 40, left: 60 };
|
const { width: wrapperWidth, height: wrapperHeight } = useElementSize(chartWrapperRef, { width: 1000, height: 300 });
|
||||||
const chartWidth = 1000 - chartPadding.left - chartPadding.right;
|
|
||||||
const chartHeight = 300 - chartPadding.top - chartPadding.bottom;
|
|
||||||
|
|
||||||
const chartPaths = computed(() => {
|
const chartPadding = { top: 20, right: 40, bottom: 40, left: 60 };
|
||||||
|
|
||||||
|
const chartViewBox = computed(() => {
|
||||||
|
const w = Math.max(wrapperWidth.value, 1);
|
||||||
|
const h = Math.max(wrapperHeight.value, 1);
|
||||||
|
return `0 0 ${w} ${h}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartPoints = computed(() => {
|
||||||
const points = trendQuery.data.value || [];
|
const points = trendQuery.data.value || [];
|
||||||
if (points.length < 2) return { subscriptions: "", usages: "" };
|
if (points.length < 2) return [];
|
||||||
|
|
||||||
const maxVal = Math.max(
|
const maxVal = Math.max(
|
||||||
...points.map(p => Math.max(p.subscriptions, p.usages)),
|
...points.map(p => Math.max(p.subscriptions, p.usages)),
|
||||||
1 // avoid division by zero
|
1 // avoid division by zero
|
||||||
) * 1.1; // 10% headroom
|
) * 1.1; // 10% headroom
|
||||||
|
|
||||||
const getX = (index: number) => chartPadding.left + (index / (points.length - 1)) * chartWidth;
|
const w = Math.max(wrapperWidth.value, 1);
|
||||||
const getY = (val: number) => chartPadding.top + chartHeight - (val / maxVal) * chartHeight;
|
const h = Math.max(wrapperHeight.value, 1);
|
||||||
|
const cWidth = Math.max(w - chartPadding.left - chartPadding.right, 1);
|
||||||
|
const cHeight = Math.max(h - chartPadding.top - chartPadding.bottom, 1);
|
||||||
|
|
||||||
let subPath = `M ${getX(0)} ${getY(points[0].subscriptions)}`;
|
const getX = (index: number) => chartPadding.left + (index / (points.length - 1)) * cWidth;
|
||||||
let usagePath = `M ${getX(0)} ${getY(points[0].usages)}`;
|
const getY = (val: number) => chartPadding.top + cHeight - (val / maxVal) * cHeight;
|
||||||
|
|
||||||
|
return points.map((p, i) => {
|
||||||
|
const x = getX(i);
|
||||||
|
let hoverX = 0;
|
||||||
|
let hoverWidth = 0;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
hoverX = chartPadding.left;
|
||||||
|
hoverWidth = (getX(1) - x) / 2;
|
||||||
|
} else if (i === points.length - 1) {
|
||||||
|
const prevX = getX(i - 1);
|
||||||
|
hoverX = prevX + (x - prevX) / 2;
|
||||||
|
hoverWidth = (x - prevX) / 2;
|
||||||
|
} else {
|
||||||
|
const prevX = getX(i - 1);
|
||||||
|
const nextX = getX(i + 1);
|
||||||
|
hoverX = prevX + (x - prevX) / 2;
|
||||||
|
hoverWidth = (x - prevX) / 2 + (nextX - x) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
x,
|
||||||
|
subY: getY(p.subscriptions),
|
||||||
|
useY: getY(p.usages),
|
||||||
|
hoverX,
|
||||||
|
hoverWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartPaths = computed(() => {
|
||||||
|
const points = chartPoints.value;
|
||||||
|
if (points.length < 2) return { subscriptions: "", usages: "" };
|
||||||
|
|
||||||
|
let subPath = `M ${points[0].x} ${points[0].subY}`;
|
||||||
|
let usagePath = `M ${points[0].x} ${points[0].useY}`;
|
||||||
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
for (let i = 1; i < points.length; i++) {
|
||||||
subPath += ` L ${getX(i)} ${getY(points[i].subscriptions)}`;
|
subPath += ` L ${points[i].x} ${points[i].subY}`;
|
||||||
usagePath += ` L ${getX(i)} ${getY(points[i].usages)}`;
|
usagePath += ` L ${points[i].x} ${points[i].useY}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { subscriptions: subPath, usages: usagePath };
|
return { subscriptions: subPath, usages: usagePath };
|
||||||
});
|
});
|
||||||
|
|
||||||
const chartAxis = computed(() => {
|
const chartAxis = computed(() => {
|
||||||
const points = trendQuery.data.value || [];
|
const points = chartPoints.value;
|
||||||
if (points.length === 0) return [];
|
if (points.length === 0) return [];
|
||||||
|
|
||||||
// Show at most 7 date labels
|
// Show at most 7 date labels
|
||||||
@@ -130,12 +176,12 @@ const chartAxis = computed(() => {
|
|||||||
const labels = [];
|
const labels = [];
|
||||||
for (let i = 0; i < points.length; i += step) {
|
for (let i = 0; i < points.length; i += step) {
|
||||||
labels.push({
|
labels.push({
|
||||||
x: chartPadding.left + (i / (points.length - 1)) * chartWidth,
|
x: points[i].x,
|
||||||
label: points[i].date.split("-").slice(1).join("/"), // MM/DD
|
label: points[i].date.split("-").slice(1).join("/"), // MM/DD
|
||||||
});
|
});
|
||||||
if (i + step >= points.length && i !== points.length - 1) {
|
if (i + step >= points.length && i !== points.length - 1) {
|
||||||
labels.push({
|
labels.push({
|
||||||
x: chartPadding.left + chartWidth,
|
x: points[points.length - 1].x,
|
||||||
label: points[points.length - 1].date.split("-").slice(1).join("/"),
|
label: points[points.length - 1].date.split("-").slice(1).join("/"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -143,6 +189,16 @@ const chartAxis = computed(() => {
|
|||||||
return labels;
|
return labels;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activePoint = ref<any>(null);
|
||||||
|
|
||||||
|
function showTooltip(point: any) {
|
||||||
|
activePoint.value = point;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTooltip() {
|
||||||
|
activePoint.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
void overviewQuery.refetch();
|
void overviewQuery.refetch();
|
||||||
void packagesQuery.refetch();
|
void packagesQuery.refetch();
|
||||||
@@ -222,19 +278,19 @@ function refresh() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chart-container" v-if="!trendQuery.isPending.value">
|
<div class="chart-container" v-if="!trendQuery.isPending.value">
|
||||||
<div v-if="trendQuery.data.value?.length" class="svg-chart-wrapper">
|
<div v-if="chartPoints.length > 0" class="svg-chart-wrapper" ref="chartWrapperRef" @mouseleave="hideTooltip">
|
||||||
<div class="chart-legend">
|
<div class="chart-legend">
|
||||||
<div class="legend-item"><span class="dot sub"></span> 订阅</div>
|
<div class="legend-item"><span class="dot sub"></span> 订阅</div>
|
||||||
<div class="legend-item"><span class="dot usage"></span> 使用</div>
|
<div class="legend-item"><span class="dot usage"></span> 使用</div>
|
||||||
</div>
|
</div>
|
||||||
<svg :viewBox="chartViewBox" class="svg-chart">
|
<svg :viewBox="chartViewBox" class="svg-chart" preserveAspectRatio="none">
|
||||||
<!-- Grid lines -->
|
<!-- Grid lines -->
|
||||||
<line
|
<line
|
||||||
v-for="i in 5" :key="i"
|
v-for="i in 5" :key="i"
|
||||||
:x1="chartPadding.left"
|
:x1="chartPadding.left"
|
||||||
:y1="chartPadding.top + (chartHeight * (i-1) / 4)"
|
:y1="chartPadding.top + (Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1) * (i-1) / 4)"
|
||||||
:x2="chartPadding.left + chartWidth"
|
:x2="chartPadding.left + Math.max(wrapperWidth - chartPadding.left - chartPadding.right, 1)"
|
||||||
:y2="chartPadding.top + (chartHeight * (i-1) / 4)"
|
:y2="chartPadding.top + (Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1) * (i-1) / 4)"
|
||||||
stroke="#f0f0f0"
|
stroke="#f0f0f0"
|
||||||
stroke-width="1"
|
stroke-width="1"
|
||||||
/>
|
/>
|
||||||
@@ -247,13 +303,50 @@ function refresh() {
|
|||||||
<text
|
<text
|
||||||
v-for="tick in chartAxis" :key="tick.x"
|
v-for="tick in chartAxis" :key="tick.x"
|
||||||
:x="tick.x"
|
:x="tick.x"
|
||||||
:y="chartPadding.top + chartHeight + 20"
|
:y="chartPadding.top + Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1) + 20"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
font-size="12"
|
font-size="12"
|
||||||
fill="#8c8c8c"
|
fill="#8c8c8c"
|
||||||
>
|
>
|
||||||
{{ tick.label }}
|
{{ tick.label }}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
|
<!-- Hover Interaction Areas -->
|
||||||
|
<rect
|
||||||
|
v-for="(point, i) in chartPoints"
|
||||||
|
:key="'hover-'+i"
|
||||||
|
:x="point.hoverX"
|
||||||
|
:y="chartPadding.top"
|
||||||
|
:width="point.hoverWidth"
|
||||||
|
:height="Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1)"
|
||||||
|
fill="transparent"
|
||||||
|
style="cursor: pointer;"
|
||||||
|
@mouseenter="showTooltip(point)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Tooltip Overlay -->
|
||||||
|
<g v-if="activePoint">
|
||||||
|
<!-- Vertical dashed line -->
|
||||||
|
<line
|
||||||
|
:x1="activePoint.x" :y1="chartPadding.top"
|
||||||
|
:x2="activePoint.x" :y2="chartPadding.top + Math.max(wrapperHeight - chartPadding.top - chartPadding.bottom, 1)"
|
||||||
|
stroke="#bfbfbf" stroke-width="1" stroke-dasharray="4 4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dots on the line -->
|
||||||
|
<circle :cx="activePoint.x" :cy="activePoint.subY" r="4" fill="#1890ff" stroke="#fff" stroke-width="2" />
|
||||||
|
<circle :cx="activePoint.x" :cy="activePoint.useY" r="4" fill="#52c41a" stroke="#fff" stroke-width="2" />
|
||||||
|
|
||||||
|
<!-- Tooltip Panel -->
|
||||||
|
<g :transform="`translate(${activePoint.x > (wrapperWidth - 160) ? activePoint.x - 140 : activePoint.x + 15}, ${chartPadding.top + 20})`">
|
||||||
|
<rect x="0" y="0" width="125" height="75" fill="#ffffff" stroke="#e8e8e8" rx="4" style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.1));" />
|
||||||
|
<text x="12" y="22" font-size="12" font-weight="bold" fill="#8c8c8c">{{ activePoint.date }}</text>
|
||||||
|
<circle cx="16" cy="42" r="4" fill="#1890ff" />
|
||||||
|
<text x="28" y="46" font-size="12" fill="#1a1a1a">订阅: {{ activePoint.subscriptions }}</text>
|
||||||
|
<circle cx="16" cy="62" r="4" fill="#52c41a" />
|
||||||
|
<text x="28" y="66" font-size="12" fill="#1a1a1a">使用: {{ activePoint.usages }}</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<a-empty v-else description="暂无趋势数据" />
|
<a-empty v-else description="暂无趋势数据" />
|
||||||
@@ -349,6 +442,7 @@ function refresh() {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||||
|
min-width: 0; /* Add to prevent grid/flex blowout */
|
||||||
}
|
}
|
||||||
.panel-title {
|
.panel-title {
|
||||||
margin: 0 0 20px 0;
|
margin: 0 0 20px 0;
|
||||||
@@ -383,16 +477,20 @@ function refresh() {
|
|||||||
height: 320px;
|
height: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.svg-chart-wrapper {
|
.svg-chart-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-height: 0; /* Fix flex item height blowout */
|
||||||
}
|
}
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
.legend-item {
|
.legend-item {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -410,9 +508,13 @@ function refresh() {
|
|||||||
.dot.usage { background: #52c41a; }
|
.dot.usage { background: #52c41a; }
|
||||||
|
|
||||||
.svg-chart {
|
.svg-chart {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-skeleton {
|
.chart-skeleton {
|
||||||
|
|||||||
Reference in New Issue
Block a user