feat(components): 新增仪表盘监控统计卡片和图表组件
This commit is contained in:
parent
269c0b60a8
commit
c299ff2e6a
283
src/views/dashboard/monitor/components/DashboardStatCard.vue
Normal file
283
src/views/dashboard/monitor/components/DashboardStatCard.vue
Normal 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>
|
||||||
906
src/views/dashboard/monitor/components/LoginTrendChart.vue
Normal file
906
src/views/dashboard/monitor/components/LoginTrendChart.vue
Normal 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>
|
||||||
423
src/views/dashboard/monitor/components/OperationPieChart.vue
Normal file
423
src/views/dashboard/monitor/components/OperationPieChart.vue
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user