coder-common-thin-frontend/src/views/dashboard/components/LoginTrendChart.vue
Leo d6bee84581 refactor(dashboard): 重构仪表盘模块架构
- 将监控相关组件重构为通用仪表盘组件
- 重命名 DashboardStatCard 和 LoginTrendChart 组件
- 重构主页面结构,简化组件层级
- 删除不再使用的图表组件和模拟数据
- 优化类型定义结构
2025-09-25 15:59:24 +08:00

907 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>