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