feat(components): 新增仪表盘监控统计卡片和图表组件

This commit is contained in:
Leo 2025-09-23 22:30:23 +08:00
parent 269c0b60a8
commit c299ff2e6a
3 changed files with 1612 additions and 0 deletions

View File

@ -0,0 +1,283 @@
<template>
<n-card class="stat-card" :style="cardStyle" hoverable>
<!-- 主要内容区域 -->
<n-space justify="space-between" align="center" class="mb-2">
<!-- 左侧数据区域 -->
<div class="stat-content">
<div class="stat-title">
{{ title }}
</div>
<div class="stat-value">
<n-number-animation
ref="numberAnimationRef"
:from="0"
:to="Number(value)"
:duration="1800"
show-separator
:active="true"
:precision="0"
:easing="cubicBezierEasing"
/>
</div>
<div class="stat-subtitle">
{{ subtitle }}
</div>
</div>
<!-- 右侧图标区域 -->
<div class="stat-icon" :style="iconStyle">
<n-icon :size="28">
<IconParkOutline:user v-if="icon === 'user'" />
<IconParkOutline:data v-else-if="icon === 'data'" />
<IconParkOutline:folderOpen v-else-if="icon === 'storage'" />
<IconParkOutline:chartLine v-else-if="icon === 'activity'" />
<IconParkOutline:user v-else />
</n-icon>
</div>
</n-space>
<!-- 底部信息区域 -->
<div class="stat-footer">
<n-space justify="space-between" align="center">
<div class="stat-extra">
{{ extraInfo }}
</div>
<div v-if="trend" class="stat-trend" :class="trendClass">
<n-icon :size="12" class="trend-icon">
<IconParkOutline:arrowUp v-if="trendUp" />
<IconParkOutline:arrowDown v-else />
</n-icon>
<span class="trend-text">{{ trend }}</span>
</div>
</n-space>
</div>
</n-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { StatCardProps } from '../types'
// Props
const props = withDefaults(defineProps<StatCardProps>(), {
trendUp: true,
})
//
const numberAnimationRef = ref()
//
function cubicBezierEasing(t: number): number {
return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
}
// -
const cardStyle = computed(() => ({
background:
'linear-gradient(135deg, var(--card-color) 0%, rgba(255, 255, 255, 0.6) 100%)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.05)',
transition: 'all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
padding: '18px',
position: 'relative',
overflow: 'hidden',
minHeight: '140px',
}))
// -
const iconStyle = computed(() => ({
color: props.color,
background: `${props.color}12`,
borderRadius: '10px',
padding: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}))
//
const trendClass = computed(() => ({
'trend-up': props.trendUp,
'trend-down': !props.trendUp,
}))
//
onMounted(() => {
//
setTimeout(
() => {
numberAnimationRef.value?.play()
},
Math.random() * 300 + 200,
) //
})
</script>
<style scoped>
.stat-card {
cursor: pointer;
transition: all 0.3s ease;
border-radius: 16px;
overflow: hidden;
position: relative;
}
.stat-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
var(--primary-color),
var(--success-color),
var(--warning-color),
var(--info-color)
);
border-radius: 12px 12px 0 0;
z-index: 1;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color-suppl);
}
.stat-card:hover::before {
height: 3px;
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
color: var(--text-color-2);
margin-bottom: 8px;
font-weight: 500;
letter-spacing: 0.3px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: var(--text-color-1);
line-height: 1.2;
margin-bottom: 6px;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
.stat-subtitle {
font-size: 12px;
color: var(--text-color-3);
font-weight: 400;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.stat-card:hover .stat-icon {
transform: scale(1.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.stat-footer {
border-top: 1px solid var(--divider-color);
padding-top: 10px;
margin-top: 6px;
}
.stat-extra {
font-size: 11px;
color: var(--text-color-3);
font-weight: 400;
}
.stat-trend {
display: flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: 500;
padding: 3px 6px;
border-radius: 4px;
transition: all 0.3s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.trend-up {
color: var(--success-color);
background: linear-gradient(
135deg,
var(--success-color-suppl),
var(--success-color-hover)
);
}
.trend-down {
color: var(--error-color);
background: linear-gradient(
135deg,
var(--error-color-suppl),
var(--error-color-hover)
);
}
.trend-icon {
transition: transform 0.3s ease;
}
.trend-text {
font-weight: 600;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stat-value {
font-size: 24px;
}
.stat-icon {
width: 48px;
height: 48px;
}
.stat-icon :deep(.n-icon) {
font-size: 22px !important;
}
.stat-title {
font-size: 13px;
}
.stat-subtitle {
font-size: 11px;
}
.stat-extra {
font-size: 10px;
}
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.stat-card:hover {
box-shadow: 0 8px 24px rgba(255, 255, 255, 0.1);
}
}
</style>

View File

@ -0,0 +1,906 @@
<template>
<div class="login-trend-chart">
<!-- 图表标题和数据概览 -->
<div class="chart-header">
<div class="chart-stats">
<div class="stat-card highlight">
<div class="stat-icon">
<div class="icon-wrapper primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="m22 21-3-3m0 0a5.5 5.5 0 1 0-7.78-7.78 5.5 5.5 0 0 0 7.78 7.78Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>
<div class="stat-content">
<div class="stat-label">
今日登录
</div>
<div class="stat-value primary">
{{ todayCount.toLocaleString() }}
</div>
<div class="stat-subtitle">
实时数据
</div>
</div>
<div class="stat-trend up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="m3 17 6-6 4 4 8-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="m14 5 7 0 0 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<div class="icon-wrapper secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2" />
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2" />
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2" />
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2" />
</svg>
</div>
</div>
<div class="stat-content">
<div class="stat-label">
昨日登录
</div>
<div class="stat-value">
{{ yesterdayCount.toLocaleString() }}
</div>
<div class="stat-subtitle">
对比数据
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<div class="icon-wrapper" :class="growthClass.positive ? 'success' : 'warning'">
<svg v-if="growthClass.positive" width="20" height="20" viewBox="0 0 24 24" fill="none">
<line x1="12" y1="19" x2="12" y2="5" stroke="currentColor" stroke-width="2" />
<polyline points="5,12 12,5 19,12" stroke="currentColor" stroke-width="2" />
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<polyline points="19,12 12,19 5,12" stroke="currentColor" stroke-width="2" />
</svg>
</div>
</div>
<div class="stat-content">
<div class="stat-label">
环比增长
</div>
<div class="stat-value" :class="growthClass">
{{ growthRate }}
</div>
<div class="stat-subtitle">
{{ growthClass.positive ? '持续上升' : '有所下降' }}
</div>
</div>
</div>
</div>
</div>
<!-- 图表主体 -->
<div ref="chartContainer" class="chart-container">
<svg
class="chart-svg"
:width="chartWidth"
:height="chartHeight"
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
>
<!-- 网格线 -->
<g class="grid-lines">
<line
v-for="(line, index) in horizontalLines"
:key="`h-${index}`"
:x1="padding.left"
:x2="chartWidth - padding.right"
:y1="line.y"
:y2="line.y"
class="grid-line"
/>
<line
v-for="(line, index) in verticalLines"
:key="`v-${index}`"
:x1="line.x"
:x2="line.x"
:y1="padding.top"
:y2="chartHeight - padding.bottom"
class="grid-line"
/>
</g>
<!-- 渐变定义 -->
<defs>
<!-- 主要区域渐变 - 简洁的蓝色渐变 -->
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.3" />
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.05" />
</linearGradient>
</defs>
<!-- 面积填充 -->
<path
:d="areaPath"
fill="url(#areaGradient)"
class="area-fill"
/>
<!-- 趋势线 -->
<path
:d="smoothLinePath"
fill="none"
stroke="#3b82f6"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="trend-line"
/>
<!-- 数据点 -->
<g class="data-points">
<circle
v-for="(point, index) in chartPoints"
:key="index"
:cx="point.x"
:cy="point.y"
r="4"
fill="#3b82f6"
class="data-point"
@mouseenter="showTooltip(point, $event)"
@mouseleave="hideTooltip"
/>
</g>
<!-- Y轴标签 -->
<g class="y-axis-labels">
<text
v-for="(label, index) in yAxisLabels"
:key="index"
:x="padding.left - 10"
:y="label.y + 5"
text-anchor="end"
class="axis-label"
>
{{ label.value }}
</text>
</g>
<!-- X轴标签 -->
<g class="x-axis-labels">
<text
v-for="(label, index) in xAxisLabels"
:key="index"
:x="label.x"
:y="chartHeight - padding.bottom + 20"
text-anchor="middle"
class="axis-label"
>
{{ label.value }}
</text>
</g>
</svg>
<!-- 工具提示 -->
<transition name="tooltip-fade">
<div
v-if="tooltip.visible"
class="chart-tooltip"
:class="tooltip.position"
:style="tooltipStyle"
>
<div class="tooltip-content">
<div class="tooltip-date">
{{ tooltip.data?.label }}
</div>
<div class="tooltip-value">
<span class="tooltip-dot" :style="{ backgroundColor: primaryColor }" />
<span class="tooltip-text">登录次数: <strong>{{ tooltip.data?.count }}</strong></span>
</div>
</div>
<div class="tooltip-arrow" :class="tooltip.arrowPosition" />
</div>
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
// Props
interface LoginTrendData {
date: string
count: number
label: string
}
const props = defineProps<{
data: LoginTrendData[]
}>()
//
const chartContainer = ref<HTMLElement>()
const primaryColor = 'var(--primary-color)'
//
const chartWidth = 800
const chartHeight = 280
const padding = {
top: 20,
right: 30,
bottom: 50,
left: 60,
}
//
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
data: null as LoginTrendData | null,
position: 'right' as 'left' | 'right',
arrowPosition: 'left' as 'left' | 'right' | 'top' | 'bottom',
})
//
const chartData = computed(() => props.data || [])
//
const todayCount = computed(() => {
const today = chartData.value[chartData.value.length - 1]
return today?.count || 0
})
const yesterdayCount = computed(() => {
const yesterday = chartData.value[chartData.value.length - 2]
return yesterday?.count || 0
})
//
const growthRate = computed(() => {
if (yesterdayCount.value === 0)
return '+0%'
const rate = ((todayCount.value - yesterdayCount.value) / yesterdayCount.value * 100)
return rate >= 0 ? `+${rate.toFixed(1)}%` : `${rate.toFixed(1)}%`
})
const growthClass = computed(() => ({
positive: todayCount.value >= yesterdayCount.value,
negative: todayCount.value < yesterdayCount.value,
}))
//
const dataRange = computed(() => {
const values = chartData.value.map(d => d.count)
const min = Math.min(...values)
const max = Math.max(...values)
const padding = (max - min) * 0.1
return {
min: Math.max(0, min - padding),
max: max + padding,
}
})
//
function getX(index: number): number {
const chartAreaWidth = chartWidth - padding.left - padding.right
return padding.left + (chartAreaWidth / (chartData.value.length - 1)) * index
}
function getY(value: number): number {
const chartAreaHeight = chartHeight - padding.top - padding.bottom
const range = dataRange.value.max - dataRange.value.min
return padding.top + chartAreaHeight - ((value - dataRange.value.min) / range) * chartAreaHeight
}
//
const chartPoints = computed(() => {
return chartData.value.map((item, index) => ({
x: getX(index),
y: getY(item.count),
data: item,
}))
})
// 线
const smoothLinePath = computed(() => {
if (chartPoints.value.length === 0)
return ''
const points = chartPoints.value
if (points.length === 1) {
return `M ${points[0].x} ${points[0].y}`
}
let path = `M ${points[0].x} ${points[0].y}`
for (let i = 1; i < points.length; i++) {
const current = points[i]
const previous = points[i - 1]
if (i === 1) {
// 使线
const controlX = previous.x + (current.x - previous.x) * 0.5
const controlY = previous.y
path += ` Q ${controlX} ${controlY} ${current.x} ${current.y}`
}
else {
// 使线
const controlPoint1X = previous.x + (current.x - previous.x) * 0.3
const controlPoint1Y = previous.y
const controlPoint2X = current.x - (current.x - previous.x) * 0.3
const controlPoint2Y = current.y
path += ` C ${controlPoint1X} ${controlPoint1Y} ${controlPoint2X} ${controlPoint2Y} ${current.x} ${current.y}`
}
}
return path
})
const areaPath = computed(() => {
if (chartPoints.value.length === 0)
return ''
const points = chartPoints.value
const bottom = chartHeight - padding.bottom
//
let path = `M ${points[0].x} ${bottom}`
path += ` L ${points[0].x} ${points[0].y}`
// 使线线
for (let i = 1; i < points.length; i++) {
const current = points[i]
const previous = points[i - 1]
if (i === 1) {
// 使线
const controlX = previous.x + (current.x - previous.x) * 0.5
const controlY = previous.y
path += ` Q ${controlX} ${controlY} ${current.x} ${current.y}`
}
else {
// 使线
const controlPoint1X = previous.x + (current.x - previous.x) * 0.3
const controlPoint1Y = previous.y
const controlPoint2X = current.x - (current.x - previous.x) * 0.3
const controlPoint2Y = current.y
path += ` C ${controlPoint1X} ${controlPoint1Y} ${controlPoint2X} ${controlPoint2Y} ${current.x} ${current.y}`
}
}
//
path += ` L ${points[points.length - 1].x} ${bottom} Z`
return path
})
// 线
const horizontalLines = computed(() => {
const lines = []
const steps = 4
for (let i = 0; i <= steps; i++) {
const value = dataRange.value.min + (dataRange.value.max - dataRange.value.min) * (i / steps)
lines.push({ y: getY(value) })
}
return lines
})
const verticalLines = computed(() => {
return chartData.value.map((_, index) => ({
x: getX(index),
}))
})
//
const yAxisLabels = computed(() => {
const labels = []
const steps = 4
for (let i = 0; i <= steps; i++) {
const value = dataRange.value.min + (dataRange.value.max - dataRange.value.min) * (i / steps)
labels.push({
y: getY(value),
value: Math.round(value).toLocaleString(),
})
}
return labels.reverse()
})
const xAxisLabels = computed(() => {
return chartData.value.map((item, index) => ({
x: getX(index),
value: item.label,
}))
})
//
const tooltipStyle = computed(() => ({
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
}))
//
function showTooltip(point: any, event: MouseEvent) {
const rect = chartContainer.value?.getBoundingClientRect()
if (!rect)
return
tooltip.visible = true
tooltip.data = point.data
// tooltip
const tooltipWidth = 140
const tooltipHeight = 70
const offset = 16
//
const mouseX = event.clientX - rect.left
const mouseY = event.clientY - rect.top
//
const isRightSide = mouseX > rect.width / 2
//
let x = mouseX + offset
tooltip.position = 'right'
tooltip.arrowPosition = 'left'
if (isRightSide) {
//
x = mouseX - tooltipWidth - offset
tooltip.position = 'left'
tooltip.arrowPosition = 'right'
}
//
x = Math.max(offset, Math.min(x, rect.width - tooltipWidth - offset))
//
let y = mouseY - tooltipHeight / 2
//
if (y < offset) {
y = mouseY + offset
tooltip.arrowPosition = 'top'
}
else if (y + tooltipHeight > rect.height - offset) {
y = mouseY - tooltipHeight - offset
tooltip.arrowPosition = 'bottom'
}
//
y = Math.max(offset, Math.min(y, rect.height - tooltipHeight - offset))
tooltip.x = x
tooltip.y = y
}
function hideTooltip() {
tooltip.visible = false
}
onMounted(() => {
//
})
</script>
<style scoped>
.login-trend-chart {
width: 100%;
height: 100%;
}
.chart-header {
margin-bottom: 24px;
padding: 0 4px;
}
.chart-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
}
.stat-card {
position: relative;
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9) 0%,
rgba(255, 255, 255, 0.6) 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
backdrop-filter: blur(20px);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
var(--primary-color) 50%,
transparent 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover::before {
opacity: 1;
}
.stat-card.highlight {
background: linear-gradient(135deg,
rgba(99, 102, 241, 0.1) 0%,
rgba(139, 92, 246, 0.05) 100%);
border-color: rgba(99, 102, 241, 0.2);
}
.stat-card.highlight::before {
opacity: 0;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.stat-icon {
flex-shrink: 0;
}
.icon-wrapper {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
position: relative;
overflow: hidden;
}
.icon-wrapper::before {
content: '';
position: absolute;
inset: 0;
background: inherit;
filter: blur(8px);
opacity: 0.3;
}
.icon-wrapper svg {
position: relative;
z-index: 1;
}
.icon-wrapper.primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.icon-wrapper.secondary {
background: linear-gradient(135deg, #64748b 0%, #475569 100%);
}
.icon-wrapper.success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.icon-wrapper.warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.stat-content {
flex: 1;
min-width: 0;
}
.stat-label {
font-size: 13px;
font-weight: 500;
color: var(--text-color-3);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-color-1);
margin-bottom: 2px;
line-height: 1.2;
}
.stat-value.primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-value.positive {
color: #10b981;
}
.stat-value.negative {
color: #ef4444;
}
.stat-subtitle {
font-size: 11px;
color: var(--text-color-3);
font-weight: 500;
}
.stat-trend {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.chart-container {
position: relative;
width: 100%;
overflow: visible;
background: var(--card-color);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--border-color);
}
.chart-svg {
width: 100%;
height: auto;
max-width: 100%;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.05));
}
.grid-line {
stroke: rgba(148, 163, 184, 0.2);
stroke-width: 0.5;
opacity: 0.8;
}
.trend-line {
filter: drop-shadow(0 2px 4px rgba(59, 130, 246, 0.2));
}
.area-fill {
opacity: 1;
}
.data-point {
cursor: pointer;
transition: all 0.2s ease;
}
.data-point:hover {
transform: scale(1.2);
filter: drop-shadow(0 3px 8px rgba(59, 130, 246, 0.3));
}
.axis-label {
font-size: 11px;
fill: var(--text-color-2);
font-family: system-ui, -apple-system, sans-serif;
font-weight: 500;
}
/* 工具提示动画 */
.tooltip-fade-enter-active,
.tooltip-fade-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.tooltip-fade-enter-from,
.tooltip-fade-leave-to {
opacity: 0;
transform: scale(0.8);
}
.chart-tooltip {
position: absolute;
background: var(--popover-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
z-index: 1000;
pointer-events: none;
font-size: 13px;
min-width: 120px;
backdrop-filter: blur(12px);
transform-origin: center;
}
.tooltip-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.tooltip-date {
font-weight: 600;
color: var(--text-color-1);
font-size: 14px;
margin-bottom: 2px;
}
.tooltip-value {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color-2);
}
.tooltip-text {
font-size: 13px;
}
.tooltip-text strong {
color: var(--primary-color);
font-weight: 600;
}
.tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid var(--card-color);
flex-shrink: 0;
}
/* 工具提示箭头 */
.tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
}
.tooltip-arrow.left {
right: 100%;
top: 50%;
transform: translateY(-50%);
border-right-color: var(--popover-color);
}
.tooltip-arrow.right {
left: 100%;
top: 50%;
transform: translateY(-50%);
border-left-color: var(--popover-color);
}
.tooltip-arrow.top {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-bottom-color: var(--popover-color);
}
.tooltip-arrow.bottom {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: var(--popover-color);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.chart-stats {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.stat-card {
padding: 16px;
}
.icon-wrapper {
width: 40px;
height: 40px;
}
.stat-value {
font-size: 20px;
}
}
@media (max-width: 768px) {
.chart-header {
margin-bottom: 20px;
}
.chart-stats {
grid-template-columns: 1fr;
gap: 12px;
}
.stat-card {
padding: 16px;
gap: 12px;
}
.icon-wrapper {
width: 36px;
height: 36px;
border-radius: 8px;
}
.stat-value {
font-size: 18px;
}
.stat-label {
font-size: 12px;
}
.stat-subtitle {
font-size: 10px;
}
.chart-container {
padding: 16px;
border-radius: 12px;
}
.stat-trend {
width: 20px;
height: 20px;
top: 8px;
right: 8px;
}
.stat-trend svg {
width: 14px;
height: 14px;
}
}
@media (max-width: 480px) {
.chart-header {
padding: 0;
}
.stat-card {
padding: 12px;
border-radius: 12px;
}
.stat-value {
font-size: 16px;
}
.chart-container {
padding: 12px;
}
}
</style>

View File

@ -0,0 +1,423 @@
<template>
<div class="operation-pie-chart">
<!-- 图表容器 -->
<div class="chart-container">
<svg
class="pie-svg"
:width="chartSize"
:height="chartSize"
:viewBox="`0 0 ${chartSize} ${chartSize}`"
>
<!-- 饼图片段 -->
<g class="pie-segments" :transform="`translate(${center}, ${center})`">
<path
v-for="(segment, index) in pieSegments"
:key="index"
:d="segment.path"
:fill="segment.color"
class="pie-segment" :class="[{ active: hoveredIndex === index }]"
@mouseenter="hoverSegment(index, $event)"
@mouseleave="unhoverSegment"
/>
</g>
<!-- 中心文字 -->
<g class="center-text" :transform="`translate(${center}, ${center})`">
<text
x="0"
y="-10"
text-anchor="middle"
class="center-title"
>
总操作数
</text>
<text
x="0"
y="15"
text-anchor="middle"
class="center-value"
>
{{ totalOperations.toLocaleString() }}
</text>
</g>
</svg>
<!-- 工具提示 -->
<div
v-if="tooltip.visible"
class="chart-tooltip"
:style="tooltipStyle"
>
<div class="tooltip-content">
<div class="tooltip-header">
<span class="tooltip-dot" :style="{ backgroundColor: tooltip.color }" />
<span class="tooltip-type">{{ tooltip.type }}</span>
</div>
<div class="tooltip-stats">
<div>数量: {{ tooltip.count?.toLocaleString() }}</div>
<div>占比: {{ tooltip.percentage }}%</div>
</div>
</div>
</div>
</div>
<!-- 图例 -->
<div class="chart-legend">
<div
v-for="(item, index) in chartData"
:key="index"
class="legend-item"
:class="{ active: hoveredIndex === index }"
@mouseenter="hoverSegment(index)"
@mouseleave="unhoverSegment"
>
<div class="legend-color" :style="{ backgroundColor: item.color }" />
<div class="legend-content">
<div class="legend-type">
{{ item.type }}
</div>
<div class="legend-stats">
<span class="legend-count">{{ item.count.toLocaleString() }}</span>
<span class="legend-percentage">{{ item.percentage }}%</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
// Props
interface OperationData {
type: string
count: number
percentage: number
color: string
}
const props = defineProps<{
data: OperationData[]
}>()
//
const hoveredIndex = ref<number | null>(null)
const chartSize = 240
const center = chartSize / 2
const radius = 80
const innerRadius = 30
//
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
type: '',
count: 0,
percentage: 0,
color: '',
})
//
const chartData = computed(() => props.data || [])
const totalOperations = computed(() => {
return chartData.value.reduce((sum, item) => sum + item.count, 0)
})
//
const pieSegments = computed(() => {
let currentAngle = -Math.PI / 2 //
return chartData.value.map((item, index) => {
const angleSize = (item.percentage / 100) * 2 * Math.PI
const startAngle = currentAngle
const endAngle = currentAngle + angleSize
//
const path = createArcPath(0, 0, innerRadius, radius, startAngle, endAngle)
currentAngle = endAngle
return {
path,
color: item.color,
data: item,
index,
}
})
})
//
function createArcPath(
centerX: number,
centerY: number,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
): string {
const startOuterX = centerX + outerRadius * Math.cos(startAngle)
const startOuterY = centerY + outerRadius * Math.sin(startAngle)
const endOuterX = centerX + outerRadius * Math.cos(endAngle)
const endOuterY = centerY + outerRadius * Math.sin(endAngle)
const startInnerX = centerX + innerRadius * Math.cos(endAngle)
const startInnerY = centerY + innerRadius * Math.sin(endAngle)
const endInnerX = centerX + innerRadius * Math.cos(startAngle)
const endInnerY = centerY + innerRadius * Math.sin(startAngle)
const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0
return [
'M',
startOuterX,
startOuterY,
'A',
outerRadius,
outerRadius,
0,
largeArcFlag,
1,
endOuterX,
endOuterY,
'L',
startInnerX,
startInnerY,
'A',
innerRadius,
innerRadius,
0,
largeArcFlag,
0,
endInnerX,
endInnerY,
'Z',
].join(' ')
}
//
const tooltipStyle = computed(() => ({
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
}))
//
function hoverSegment(index: number, event?: MouseEvent) {
hoveredIndex.value = index
const item = chartData.value[index]
if (event) {
const rect = (event.target as Element).closest('.chart-container')?.getBoundingClientRect()
if (rect) {
tooltip.visible = true
tooltip.x = event.clientX - rect.left + 10
tooltip.y = event.clientY - rect.top - 40
tooltip.type = item.type
tooltip.count = item.count
tooltip.percentage = item.percentage
tooltip.color = item.color
}
}
}
function unhoverSegment() {
hoveredIndex.value = null
tooltip.visible = false
}
</script>
<style scoped>
.operation-pie-chart {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
padding: 16px;
}
.chart-container {
position: relative;
margin-bottom: 20px;
}
.pie-svg {
width: 100%;
height: auto;
}
.pie-segment {
cursor: pointer;
transition: all 0.3s ease;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
.pie-segment:hover,
.pie-segment.active {
transform: scale(1.05);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
}
.center-text .center-title {
font-size: 12px;
fill: var(--text-color-2);
font-weight: 500;
}
.center-text .center-value {
font-size: 16px;
fill: var(--text-color-1);
font-weight: bold;
}
.chart-tooltip {
position: absolute;
background: var(--popover-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
pointer-events: none;
font-size: 12px;
min-width: 120px;
}
.tooltip-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.tooltip-header {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
color: var(--text-color-1);
}
.tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.tooltip-stats {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--text-color-2);
font-size: 11px;
}
.chart-legend {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 200px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.legend-item:hover,
.legend-item.active {
background: var(--hover-color);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-content {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.legend-type {
font-size: 13px;
color: var(--text-color-1);
font-weight: 500;
}
.legend-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.legend-count {
font-size: 12px;
color: var(--text-color-1);
font-weight: 600;
}
.legend-percentage {
font-size: 11px;
color: var(--text-color-3);
}
/* 响应式设计 */
@media (max-width: 768px) {
.operation-pie-chart {
padding: 12px;
}
.chart-container {
margin-bottom: 16px;
}
.legend-item {
padding: 4px 6px;
}
.legend-type {
font-size: 12px;
}
.legend-count {
font-size: 11px;
}
}
/* 动画效果 */
.pie-segment {
transform-origin: center;
animation: fadeInScale 0.6s ease-out forwards;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 为每个片段添加延迟动画 */
.pie-segment:nth-child(1) { animation-delay: 0.1s; }
.pie-segment:nth-child(2) { animation-delay: 0.2s; }
.pie-segment:nth-child(3) { animation-delay: 0.3s; }
.pie-segment:nth-child(4) { animation-delay: 0.4s; }
.pie-segment:nth-child(5) { animation-delay: 0.5s; }
</style>