|
16 | 16 | <div class="metric-card"> |
17 | 17 | <div class="metric-label">总短链数</div> |
18 | 18 | <div class="metric-value">{{ globalStats?.totalUrls ?? '-' }}</div> |
| 19 | + <div class="metric-trend" v-if="globalStats?.totalUrls"> |
| 20 | + <span class="trend-icon">•</span> 系统总计 |
| 21 | + </div> |
19 | 22 | </div> |
20 | 23 | <div class="metric-card"> |
21 | 24 | <div class="metric-label">总点击量(PV)</div> |
22 | 25 | <div class="metric-value">{{ globalStats?.totalClicks ?? '-' }}</div> |
| 26 | + <div class="metric-trend positive" v-if="globalStats?.totalClicks"> |
| 27 | + <span class="trend-icon">↑</span> 累计访问 |
| 28 | + </div> |
23 | 29 | </div> |
24 | 30 | <div class="metric-card"> |
25 | 31 | <div class="metric-label">独立访客(UV)</div> |
26 | 32 | <div class="metric-value">{{ globalStats?.totalUniqueIps ?? '-' }}</div> |
| 33 | + <div class="metric-trend" v-if="globalStats?.totalUniqueIps"> |
| 34 | + <span class="trend-icon">•</span> 独立 IP |
| 35 | + </div> |
27 | 36 | </div> |
28 | | - <div class="metric-card"> |
| 37 | + <div class="metric-card highlight"> |
29 | 38 | <div class="metric-label">今日点击</div> |
30 | 39 | <div class="metric-value">{{ globalStats?.todayClicks ?? '-' }}</div> |
| 40 | + <div class="metric-trend positive" v-if="globalStats?.todayClicks"> |
| 41 | + <span class="trend-icon">•</span> 实时数据 |
| 42 | + </div> |
31 | 43 | </div> |
32 | 44 | <div class="metric-card"> |
33 | 45 | <div class="metric-label">活跃短链</div> |
34 | 46 | <div class="metric-value">{{ globalStats?.activeUrls ?? '-' }}</div> |
| 47 | + <div class="metric-trend" v-if="globalStats?.activeUrls"> |
| 48 | + <span class="trend-icon">•</span> 有访问记录 |
| 49 | + </div> |
35 | 50 | </div> |
36 | 51 | </div> |
37 | 52 |
|
|
48 | 63 | </div> |
49 | 64 | </div> |
50 | 65 | <div class="card-body"> |
51 | | - <div class="chart-container"> |
| 66 | + <div class="chart-container-enhanced"> |
52 | 67 | <Suspense> |
53 | 68 | <TrendChart :values="dailyTrendValues" :labels="dailyTrendLabels" :showValues="true" /> |
54 | | - <template #fallback><div class="chart-placeholder"></div></template> |
| 69 | + <template #fallback><div class="chart-placeholder">加载中...</div></template> |
55 | 70 | </Suspense> |
| 71 | + <!-- 数据洞察提示 --> |
| 72 | + <div v-if="trendInsight" class="chart-insight"> |
| 73 | + <span class="insight-text">{{ trendInsight }}</span> |
| 74 | + </div> |
56 | 75 | </div> |
57 | 76 | </div> |
58 | 77 | </div> |
|
61 | 80 | <div class="card"> |
62 | 81 | <div class="card-header">设备分布</div> |
63 | 82 | <div class="card-body"> |
64 | | - <Suspense> |
65 | | - <PieDonut :data="devicePieData" :colors="['#2563eb', '#60a5fa', '#93c5fd', '#bfdbfe']" /> |
66 | | - <template #fallback><div class="chart-placeholder"></div></template> |
67 | | - </Suspense> |
| 83 | + <div class="chart-container-enhanced"> |
| 84 | + <Suspense> |
| 85 | + <PieDonut :data="devicePieData" :colors="['#2563eb', '#60a5fa', '#93c5fd', '#bfdbfe']" /> |
| 86 | + <template #fallback><div class="chart-placeholder">加载中...</div></template> |
| 87 | + </Suspense> |
| 88 | + <!-- 设备统计说明 --> |
| 89 | + <div v-if="deviceSummary" class="chart-insight"> |
| 90 | + <span class="insight-text">{{ deviceSummary }}</span> |
| 91 | + </div> |
| 92 | + </div> |
68 | 93 | </div> |
69 | 94 | </div> |
70 | 95 |
|
71 | 96 | <!-- 城市TOP10 --> |
72 | 97 | <div class="card"> |
73 | 98 | <div class="card-header">城市 TOP 10</div> |
74 | 99 | <div class="card-body"> |
75 | | - <div class="rank-list"> |
76 | | - <div v-for="(item, idx) in cityTop10" :key="item.key" class="rank-item"> |
77 | | - <span class="rank-num">{{ idx + 1 }}</span> |
| 100 | + <div class="rank-list-enhanced"> |
| 101 | + <div v-for="(item, idx) in cityTop10" :key="item.key" class="rank-item-enhanced"> |
| 102 | + <span class="rank-badge" :class="{ 'rank-top3': idx < 3 }">{{ idx + 1 }}</span> |
78 | 103 | <span class="rank-name">{{ item.key || '未知' }}</span> |
| 104 | + <div class="rank-bar"> |
| 105 | + <div class="rank-bar-fill" :style="{ width: getBarWidth(item.count, cityTop10) + '%' }"></div> |
| 106 | + </div> |
79 | 107 | <span class="rank-count">{{ item.count }}</span> |
80 | 108 | </div> |
81 | | - <div v-if="!cityTop10.length" class="empty-state">暂无数据</div> |
| 109 | + <div v-if="!cityTop10.length" class="empty-state-enhanced"> |
| 110 | + <div class="empty-text">暂无城市数据</div> |
| 111 | + <div class="empty-hint">当有访问者点击短链时,此处将显示城市分布</div> |
| 112 | + </div> |
82 | 113 | </div> |
83 | 114 | </div> |
84 | 115 | </div> |
|
87 | 118 | <div class="card"> |
88 | 119 | <div class="card-header">来源域名 TOP 10</div> |
89 | 120 | <div class="card-body"> |
90 | | - <div class="rank-list"> |
91 | | - <div v-for="(item, idx) in sourceTop10" :key="item.key" class="rank-item"> |
92 | | - <span class="rank-num">{{ idx + 1 }}</span> |
| 121 | + <div class="rank-list-enhanced"> |
| 122 | + <div v-for="(item, idx) in sourceTop10" :key="item.key" class="rank-item-enhanced"> |
| 123 | + <span class="rank-badge" :class="{ 'rank-top3': idx < 3 }">{{ idx + 1 }}</span> |
93 | 124 | <span class="rank-name truncate">{{ item.key || '直接访问' }}</span> |
| 125 | + <div class="rank-bar"> |
| 126 | + <div class="rank-bar-fill" :style="{ width: getBarWidth(item.count, sourceTop10) + '%' }"></div> |
| 127 | + </div> |
94 | 128 | <span class="rank-count">{{ item.count }}</span> |
95 | 129 | </div> |
96 | | - <div v-if="!sourceTop10.length" class="empty-state">暂无数据</div> |
| 130 | + <div v-if="!sourceTop10.length" class="empty-state-enhanced"> |
| 131 | + <div class="empty-text">暂无来源数据</div> |
| 132 | + <div class="empty-hint">系统将记录访问者来源,分析流量渠道</div> |
| 133 | + </div> |
97 | 134 | </div> |
98 | 135 | </div> |
99 | 136 | </div> |
@@ -258,6 +295,44 @@ const devicePieData = computed(() => { |
258 | 295 | })) |
259 | 296 | }) |
260 | 297 |
|
| 298 | +// 趋势洞察 |
| 299 | +const trendInsight = computed(() => { |
| 300 | + const values = dailyTrendValues.value |
| 301 | + if (!values || values.length < 2) return '' |
| 302 | + |
| 303 | + const total = values.reduce((a, b) => a + b, 0) |
| 304 | + const avg = Math.round(total / values.length) |
| 305 | + const lastValue = values[values.length - 1] |
| 306 | + const prevValue = values[values.length - 2] |
| 307 | + |
| 308 | + if (lastValue > prevValue) { |
| 309 | + const growth = Math.round((lastValue - prevValue) / Math.max(prevValue, 1) * 100) |
| 310 | + return `近${trendDays.value}天平均每天 ${avg} 次访问,最近一天增长 ${growth}%` |
| 311 | + } else if (lastValue < prevValue) { |
| 312 | + return `近${trendDays.value}天平均每天 ${avg} 次访问,最近一天略有下降` |
| 313 | + } else { |
| 314 | + return `近${trendDays.value}天平均每天 ${avg} 次访问,访问量保持稳定` |
| 315 | + } |
| 316 | +}) |
| 317 | +
|
| 318 | +// 设备分布总结 |
| 319 | +const deviceSummary = computed(() => { |
| 320 | + const devices = deviceDistribution.value |
| 321 | + if (!devices || devices.length === 0) return '' |
| 322 | + |
| 323 | + const total = devices.reduce((a, b) => a + b.count, 0) |
| 324 | + const topDevice = devices.reduce((max, item) => item.count > max.count ? item : max, devices[0]) |
| 325 | + const percent = Math.round(topDevice.count / total * 100) |
| 326 | + |
| 327 | + const deviceName = { |
| 328 | + 'desktop': '桌面设备', |
| 329 | + 'mobile': '移动设备', |
| 330 | + 'tablet': '平板设备' |
| 331 | + }[topDevice.key] || topDevice.key |
| 332 | + |
| 333 | + return `${deviceName}访问占比最高,达 ${percent}%` |
| 334 | +}) |
| 335 | +
|
261 | 336 | const filteredList = computed(() => { |
262 | 337 | const q = searchQuery.value.trim().toLowerCase() |
263 | 338 | const list = urlList.value || [] |
@@ -286,6 +361,13 @@ function getSharePercent(url) { |
286 | 361 | return totalClicks.value > 0 ? (Number(url.totalVisits || 0) / totalClicks.value * 100) : 0 |
287 | 362 | } |
288 | 363 |
|
| 364 | +// 计算条形图宽度 |
| 365 | +function getBarWidth(count, dataArray) { |
| 366 | + if (!dataArray || dataArray.length === 0) return 0 |
| 367 | + const maxCount = Math.max(...dataArray.map(item => item.count)) |
| 368 | + return maxCount > 0 ? (count / maxCount * 100) : 0 |
| 369 | +} |
| 370 | +
|
289 | 371 | // 复制状态 |
290 | 372 | const copyingCode = ref(null) |
291 | 373 |
|
@@ -334,11 +416,15 @@ async function fetchUrlList() { |
334 | 416 | try { |
335 | 417 | const res = await axios.get(`${API_BASE}/api/urls?page=${page.value - 1}&size=${pageSize}`) |
336 | 418 | const data = res.data |
| 419 | + console.log('fetchUrlList response:', data) // 调试信息 |
337 | 420 | urlList.value = Array.isArray(data) ? data : (data.content || data.items || []) |
338 | 421 | totalElements.value = data.totalElements || urlList.value.length |
339 | 422 | totalPages.value = data.totalPages || Math.ceil(totalElements.value / pageSize) || 1 |
| 423 | + console.log('urlList.value:', urlList.value) // 调试信息 |
| 424 | + console.log('totalElements:', totalElements.value) |
340 | 425 | } catch (e) { |
341 | 426 | console.error('fetchUrlList error:', e) |
| 427 | + urlList.value = [] |
342 | 428 | } finally { |
343 | 429 | loading.value = false |
344 | 430 | } |
@@ -748,4 +834,142 @@ tr:nth-child(n+4) .rank-badge { |
748 | 834 | text-overflow: ellipsis; |
749 | 835 | white-space: nowrap; |
750 | 836 | } |
| 837 | +
|
| 838 | +/* ========== 新增:增强视觉样式 ========== */ |
| 839 | +
|
| 840 | +/* 指标卡片增强 */ |
| 841 | +.metric-card.highlight { |
| 842 | + border-color: #3b82f6; |
| 843 | + background: #eff6ff; |
| 844 | +} |
| 845 | +
|
| 846 | +.metric-trend { |
| 847 | + margin-top: 8px; |
| 848 | + font-size: 12px; |
| 849 | + color: #64748b; |
| 850 | + display: flex; |
| 851 | + align-items: center; |
| 852 | + justify-content: center; |
| 853 | + gap: 4px; |
| 854 | +} |
| 855 | +
|
| 856 | +.metric-trend.positive { |
| 857 | + color: #16a34a; |
| 858 | +} |
| 859 | +
|
| 860 | +.trend-icon { |
| 861 | + font-size: 14px; |
| 862 | +} |
| 863 | +
|
| 864 | +/* 图表容器增强 */ |
| 865 | +.chart-container-enhanced { |
| 866 | + min-height: 320px; |
| 867 | + position: relative; |
| 868 | +} |
| 869 | +
|
| 870 | +.chart-placeholder { |
| 871 | + height: 280px; |
| 872 | + display: flex; |
| 873 | + align-items: center; |
| 874 | + justify-content: center; |
| 875 | + color: #94a3b8; |
| 876 | + font-size: 14px; |
| 877 | + background: #f8fafc; |
| 878 | + border: 1px dashed #e2e8f0; |
| 879 | + border-radius: 6px; |
| 880 | +} |
| 881 | +
|
| 882 | +/* 图表洞察提示 */ |
| 883 | +.chart-insight { |
| 884 | + margin-top: 12px; |
| 885 | + padding: 12px 16px; |
| 886 | + background: #f0f9ff; |
| 887 | + border-left: 3px solid #3b82f6; |
| 888 | + border-radius: 4px; |
| 889 | + font-size: 13px; |
| 890 | + color: #1e40af; |
| 891 | +} |
| 892 | +
|
| 893 | +.insight-text { |
| 894 | + line-height: 1.5; |
| 895 | +} |
| 896 | +
|
| 897 | +/* 排名列表增强 */ |
| 898 | +.rank-list-enhanced { |
| 899 | + display: flex; |
| 900 | + flex-direction: column; |
| 901 | + gap: 12px; |
| 902 | + min-height: 200px; |
| 903 | +} |
| 904 | +
|
| 905 | +.rank-item-enhanced { |
| 906 | + display: grid; |
| 907 | + grid-template-columns: 32px 1fr 100px 60px; |
| 908 | + align-items: center; |
| 909 | + gap: 12px; |
| 910 | + padding: 10px 0; |
| 911 | + border-bottom: 1px solid #f1f5f9; |
| 912 | +} |
| 913 | +
|
| 914 | +.rank-item-enhanced:last-child { |
| 915 | + border-bottom: none; |
| 916 | +} |
| 917 | +
|
| 918 | +.rank-badge { |
| 919 | + width: 28px; |
| 920 | + height: 28px; |
| 921 | + display: flex; |
| 922 | + align-items: center; |
| 923 | + justify-content: center; |
| 924 | + background: #cbd5e1; |
| 925 | + color: white; |
| 926 | + font-size: 13px; |
| 927 | + font-weight: 600; |
| 928 | + border-radius: 50%; |
| 929 | + flex-shrink: 0; |
| 930 | +} |
| 931 | +
|
| 932 | +.rank-badge.rank-top3 { |
| 933 | + background: #3b82f6; |
| 934 | +} |
| 935 | +
|
| 936 | +.rank-bar { |
| 937 | + height: 8px; |
| 938 | + background: #f1f5f9; |
| 939 | + border-radius: 4px; |
| 940 | + overflow: hidden; |
| 941 | + position: relative; |
| 942 | +} |
| 943 | +
|
| 944 | +.rank-bar-fill { |
| 945 | + height: 100%; |
| 946 | + background: #3b82f6; |
| 947 | + border-radius: 4px; |
| 948 | + transition: width 0.5s ease; |
| 949 | +} |
| 950 | +
|
| 951 | +/* 空状态增强 */ |
| 952 | +.empty-state-enhanced { |
| 953 | + padding: 60px 20px; |
| 954 | + text-align: center; |
| 955 | + min-height: 200px; |
| 956 | + display: flex; |
| 957 | + flex-direction: column; |
| 958 | + align-items: center; |
| 959 | + justify-content: center; |
| 960 | +} |
| 961 | +
|
| 962 | +.empty-text { |
| 963 | + font-size: 16px; |
| 964 | + font-weight: 500; |
| 965 | + color: #64748b; |
| 966 | + margin-bottom: 8px; |
| 967 | +} |
| 968 | +
|
| 969 | +.empty-hint { |
| 970 | + font-size: 13px; |
| 971 | + color: #94a3b8; |
| 972 | + max-width: 300px; |
| 973 | + line-height: 1.6; |
| 974 | +} |
751 | 975 | </style> |
0 commit comments