admin_users_personels

This commit is contained in:
Roo
2026-06-29 14:11:15 +00:00
parent 383e5511b6
commit df4a0f07ff
237 changed files with 17849 additions and 2934 deletions

View File

@@ -0,0 +1,366 @@
<template>
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-semibold text-white">{{ $t('users.trend_title') }}</h3>
<p class="text-xs text-slate-400 mt-0.5">{{ $t('users.trend_subtitle') }}</p>
</div>
<!-- Period selector -->
<select
v-model="selectedPeriod"
class="px-2.5 py-1.5 text-xs bg-slate-700 border border-slate-600 rounded-lg text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
@change="updateChart"
>
<option value="7">{{ $t('users.trend_7d') }}</option>
<option value="14">{{ $t('users.trend_14d') }}</option>
<option value="30">{{ $t('users.trend_30d') }}</option>
<option value="90">{{ $t('users.trend_90d') }}</option>
</select>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<svg class="w-6 h-6 text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<!-- Empty State -->
<div v-else-if="filteredData.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
<svg class="w-10 h-10 text-slate-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-slate-500 text-xs">{{ $t('users.trend_no_data') }}</p>
</div>
<!-- Chart Area -->
<div v-else class="relative">
<!-- SVG Bar Chart -->
<svg
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
class="w-full h-auto overflow-visible"
preserveAspectRatio="xMidYMid meet"
>
<!-- Grid lines -->
<line
v-for="(y, yi) in gridLines"
:key="'grid-' + yi"
:x1="padding.left"
:y1="y"
:x2="svgWidth - padding.right"
:y2="y"
stroke="currentColor"
class="text-slate-700/50"
stroke-width="1"
stroke-dasharray="4,4"
/>
<!-- Y-axis labels -->
<text
v-for="(label, li) in yLabels"
:key="'ylabel-' + li"
:x="padding.left - 8"
:y="label.y + 4"
text-anchor="end"
class="fill-slate-500"
font-size="10"
>
{{ label.text }}
</text>
<!-- Bars -->
<g
v-for="(item, idx) in visibleBars"
:key="'bar-' + idx"
class="chart-bar-group"
@mouseenter="hoveredBar = idx"
@mouseleave="hoveredBar = null"
>
<rect
:x="item.x"
:y="item.y"
:width="barWidth"
:height="item.height"
:rx="3"
:ry="3"
:fill="item.color"
:opacity="hoveredBar === idx ? 0.9 : 0.7"
class="transition-all duration-200 cursor-pointer"
/>
<!-- Tooltip -->
<g v-if="hoveredBar === idx">
<rect
:x="item.tooltipX"
:y="item.tooltipY - 28"
:width="item.tooltipWidth"
height="22"
rx="4"
fill="#1e293b"
stroke="#475569"
stroke-width="1"
/>
<text
:x="item.tooltipX + item.tooltipWidth / 2"
:y="item.tooltipY - 13"
text-anchor="middle"
fill="#e2e8f0"
font-size="11"
font-weight="600"
>
{{ item.tooltipText }}
</text>
</g>
</g>
<!-- X-axis labels (show every Nth label to avoid crowding) -->
<text
v-for="(item, xi) in xLabels"
:key="'xlabel-' + xi"
:x="item.x + barWidth / 2"
:y="svgHeight - padding.bottom + 16"
text-anchor="middle"
class="fill-slate-500"
font-size="9"
>
{{ item.label }}
</text>
</svg>
<!-- Summary Stats Below Chart -->
<div class="flex items-center justify-between mt-4 pt-3 border-t border-slate-700/50">
<div class="flex items-center gap-4 text-xs">
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-sm bg-indigo-500/70"></span>
<span class="text-slate-400">{{ $t('users.trend_daily') }}</span>
</div>
<div>
<span class="text-slate-400">{{ $t('users.trend_avg') }}:</span>
<span class="text-white font-semibold ml-1">{{ averageCount }}</span>
</div>
<div>
<span class="text-slate-400">{{ $t('users.trend_total') }}:</span>
<span class="text-white font-semibold ml-1">{{ totalCount }}</span>
</div>
</div>
<div v-if="trendDirection !== 'stable'" class="flex items-center gap-1 text-xs">
<svg
v-if="trendDirection === 'up'"
class="w-3.5 h-3.5 text-emerald-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<svg
v-else
class="w-3.5 h-3.5 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6" />
</svg>
<span :class="trendDirection === 'up' ? 'text-emerald-400' : 'text-red-400'" class="font-medium">
{{ trendPercent }}%
</span>
<span class="text-slate-500">{{ $t('users.trend_vs_prev') }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
interface TrendDataPoint {
date: string
count: number
}
const props = defineProps<{
data: TrendDataPoint[]
loading?: boolean
}>()
// ── State ──────────────────────────────────────────────────────────
const selectedPeriod = ref('30')
const hoveredBar = ref<number | null>(null)
// ── Chart Dimensions ────────────────────────────────────────────────
const svgWidth = 800
const svgHeight = 220
const padding = { top: 10, right: 16, bottom: 30, left: 36 }
// ── Computed ───────────────────────────────────────────────────────
const filteredData = computed(() => {
const days = parseInt(selectedPeriod.value, 10)
if (!props.data || props.data.length === 0) return []
// Sort by date ascending
const sorted = [...props.data].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
)
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - days)
return sorted.filter((d) => new Date(d.date) >= cutoff)
})
const maxCount = computed(() => {
if (filteredData.value.length === 0) return 1
return Math.max(...filteredData.value.map((d) => d.count), 1)
})
const chartHeight = svgHeight - padding.top - padding.bottom
// Grid lines (5 horizontal lines)
const gridLines = computed(() => {
const lines = []
for (let i = 0; i <= 4; i++) {
lines.push(padding.top + (chartHeight / 4) * i)
}
return lines
})
const yLabels = computed(() => {
const labels = []
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartHeight / 4) * i
const value = Math.round(maxCount.value - (maxCount.value / 4) * i)
labels.push({ y, text: value.toString() })
}
return labels
})
const barWidth = computed(() => {
const count = filteredData.value.length
if (count === 0) return 0
const availableWidth = svgWidth - padding.left - padding.right
const maxBarWidth = 32
const calculatedWidth = (availableWidth / count) * 0.7
return Math.min(calculatedWidth, maxBarWidth)
})
const visibleBars = computed(() => {
const count = filteredData.value.length
if (count === 0) return []
const availableWidth = svgWidth - padding.left - padding.right
const gap = count > 1 ? (availableWidth - barWidth.value * count) / (count - 1) : 0
return filteredData.value.map((item, idx) => {
const x = padding.left + idx * (barWidth.value + gap)
const barHeight = maxCount.value > 0 ? (item.count / maxCount.value) * chartHeight : 0
const y = svgHeight - padding.bottom - barHeight
// Color gradient based on value
const ratio = maxCount.value > 0 ? item.count / maxCount.value : 0
let color: string
if (ratio > 0.8) color = '#6366f1' // indigo-500
else if (ratio > 0.5) color = '#818cf8' // indigo-400
else if (ratio > 0.3) color = '#a5b4fc' // indigo-300
else color = '#c7d2fe' // indigo-200
// Tooltip
const tooltipText = `${item.count} ${formatDate(item.date)}`
const tooltipWidth = tooltipText.length * 7 + 16
let tooltipX = x + barWidth.value / 2 - tooltipWidth / 2
if (tooltipX < padding.left) tooltipX = padding.left
if (tooltipX + tooltipWidth > svgWidth - padding.right) tooltipX = svgWidth - padding.right - tooltipWidth
const tooltipY = y - 4
return {
x,
y,
height: barHeight,
color,
tooltipX,
tooltipY,
tooltipWidth,
tooltipText,
}
})
})
// X-axis labels (show every Nth)
const xLabels = computed(() => {
const count = filteredData.value.length
if (count === 0) return []
const availableWidth = svgWidth - padding.left - padding.right
const gap = count > 1 ? (availableWidth - barWidth.value * count) / (count - 1) : 0
// Determine label interval based on data density
let interval = 1
if (count > 14) interval = 2
if (count > 30) interval = 5
if (count > 60) interval = 10
return filteredData.value
.map((item, idx) => {
const x = padding.left + idx * (barWidth.value + gap)
const d = new Date(item.date)
const label = `${d.getMonth() + 1}/${d.getDate()}`
return { x, label, idx }
})
.filter((_, i) => i % interval === 0 || i === count - 1)
})
// Summary stats
const averageCount = computed(() => {
if (filteredData.value.length === 0) return 0
const sum = filteredData.value.reduce((acc, d) => acc + d.count, 0)
return Math.round(sum / filteredData.value.length)
})
const totalCount = computed(() => {
return filteredData.value.reduce((acc, d) => acc + d.count, 0)
})
const trendDirection = computed(() => {
const data = filteredData.value
if (data.length < 4) return 'stable'
const half = Math.floor(data.length / 2)
const firstHalf = data.slice(0, half).reduce((acc, d) => acc + d.count, 0) / half
const secondHalf = data.slice(half).reduce((acc, d) => acc + d.count, 0) / (data.length - half)
if (secondHalf > firstHalf * 1.05) return 'up'
if (secondHalf < firstHalf * 0.95) return 'down'
return 'stable'
})
const trendPercent = computed(() => {
const data = filteredData.value
if (data.length < 4) return 0
const half = Math.floor(data.length / 2)
const firstHalf = data.slice(0, half).reduce((acc, d) => acc + d.count, 0) / half
if (firstHalf === 0) return 100
const secondHalf = data.slice(half).reduce((acc, d) => acc + d.count, 0) / (data.length - half)
return Math.round(Math.abs((secondHalf - firstHalf) / firstHalf) * 100)
})
// ── Helpers ────────────────────────────────────────────────────────
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr)
return `${d.getMonth() + 1}/${d.getDate()}`
} catch {
return dateStr
}
}
function updateChart() {
// Trigger reactivity by clearing and resetting hover
hoveredBar.value = null
}
// ── Watch for data changes ─────────────────────────────────────────
watch(() => props.data, () => {
updateChart()
})
</script>