- 将监控相关组件重构为通用仪表盘组件 - 重命名 DashboardStatCard 和 LoginTrendChart 组件 - 重构主页面结构,简化组件层级 - 删除不再使用的图表组件和模拟数据 - 优化类型定义结构
907 lines
21 KiB
Vue
907 lines
21 KiB
Vue
<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>
|