Add Vue frontend application
This commit is contained in:
74
frontend/src/components/stats/MetricCard.vue
Normal file
74
frontend/src/components/stats/MetricCard.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon: Component
|
||||
label: string
|
||||
value: string | number
|
||||
accentColor?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="metric-card" :style="{ '--card-accent': accentColor || 'var(--accent-cyan)' }">
|
||||
<div class="metric-icon">
|
||||
<component :is="icon" :size="16" />
|
||||
</div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ value }}</span>
|
||||
<span class="metric-label">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-mid);
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
border-color: var(--card-accent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--card-accent) 30%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--card-accent) 12%, transparent);
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
.metric-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--card-accent);
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--card-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
62
frontend/src/components/stats/MiniBarChart.vue
Normal file
62
frontend/src/components/stats/MiniBarChart.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: number[]
|
||||
color?: string
|
||||
height?: number
|
||||
maxBars?: number
|
||||
}>(), {
|
||||
color: 'var(--accent-cyan)',
|
||||
height: 32,
|
||||
maxBars: 7
|
||||
})
|
||||
|
||||
const displayData = computed(() => {
|
||||
if (props.data.length <= props.maxBars) return props.data
|
||||
// 采样:取最后 N 个
|
||||
return props.data.slice(-props.maxBars)
|
||||
})
|
||||
|
||||
const maxValue = computed(() => Math.max(...displayData.value, 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mini-bar-chart" :style="{ '--chart-height': height + 'px', '--chart-color': color }">
|
||||
<div class="bars">
|
||||
<div
|
||||
v-for="(value, i) in displayData"
|
||||
:key="i"
|
||||
class="bar"
|
||||
:style="{ height: (value / maxValue * 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mini-bar-chart {
|
||||
width: 100%;
|
||||
height: var(--chart-height);
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
background: var(--chart-color);
|
||||
border-radius: 2px 2px 0 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
min-height: 2px;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
87
frontend/src/components/stats/MiniLineChart.vue
Normal file
87
frontend/src/components/stats/MiniLineChart.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: { date: string; value: number }[]
|
||||
color?: string
|
||||
height?: number
|
||||
maxPoints?: number
|
||||
}>(), {
|
||||
color: 'var(--accent-cyan)',
|
||||
height: 60,
|
||||
maxPoints: 30
|
||||
})
|
||||
|
||||
const displayData = computed(() => {
|
||||
if (props.data.length <= props.maxPoints) return props.data
|
||||
// 采样:均匀抽取
|
||||
const step = Math.ceil(props.data.length / props.maxPoints)
|
||||
return props.data.filter((_, i) => i % step === 0)
|
||||
})
|
||||
|
||||
const maxValue = computed(() => Math.max(...displayData.value.map(d => d.value), 1))
|
||||
|
||||
const points = computed(() => {
|
||||
return displayData.value.map((d, i) => {
|
||||
const x = (i / (displayData.value.length - 1)) * 100
|
||||
const y = 100 - (d.value / maxValue.value * 100)
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mini-line-chart" :style="{ '--chart-height': height + 'px', '--chart-color': color }">
|
||||
<svg :viewBox="`0 0 100 100`" preserveAspectRatio="none" class="chart-svg">
|
||||
<!-- 背景网格 -->
|
||||
<line x1="0" y1="50" x2="100" y2="50" class="grid-line" />
|
||||
<!-- 折线 -->
|
||||
<polyline :points="points" class="line" />
|
||||
<!-- 数据点 -->
|
||||
<circle
|
||||
v-for="(d, i) in displayData"
|
||||
:key="i"
|
||||
:cx="(i / (displayData.length - 1)) * 100"
|
||||
:cy="100 - (d.value / maxValue * 100)"
|
||||
r="2"
|
||||
class="dot"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mini-line-chart {
|
||||
width: 100%;
|
||||
height: var(--chart-height);
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
stroke: var(--border-dim);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.line {
|
||||
fill: none;
|
||||
stroke: var(--chart-color);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dot {
|
||||
fill: var(--chart-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.mini-line-chart:hover .dot {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
73
frontend/src/components/stats/SectionHeader.vue
Normal file
73
frontend/src/components/stats/SectionHeader.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
tag?: 'cyan' | 'purple' | 'amber'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<span class="section-slash">//</span>
|
||||
<span class="section-name">{{ title }}</span>
|
||||
</div>
|
||||
<span v-if="tag" class="section-tag" :class="tag">
|
||||
{{ title.split(' ')[0] }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0 12px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.section-slash {
|
||||
color: var(--text-dim);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.section-name {
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-tag {
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-tag.cyan {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.section-tag.purple {
|
||||
background: var(--accent-purple-dim);
|
||||
color: var(--accent-purple);
|
||||
border: 1px solid rgba(123, 44, 191, 0.2);
|
||||
}
|
||||
|
||||
.section-tag.amber {
|
||||
background: var(--accent-amber-dim);
|
||||
color: var(--accent-amber);
|
||||
border: 1px solid rgba(249, 168, 37, 0.2);
|
||||
}
|
||||
</style>
|
||||
55
frontend/src/components/stats/SummaryRow.vue
Normal file
55
frontend/src/components/stats/SummaryRow.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
items: { label: string; value: string | number }[]
|
||||
columns?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="summary-row" :style="{ '--cols': columns || 4 }">
|
||||
<div v-for="item in items" :key="item.label" class="summary-item">
|
||||
<span class="summary-value">{{ item.value }}</span>
|
||||
<span class="summary-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.summary-item:hover {
|
||||
border-color: var(--border-mid);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 8px var(--accent-cyan-glow);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user