befly-admin-ui 1.8.32 → 1.8.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jsconfig.json CHANGED
@@ -2,7 +2,6 @@
2
2
  "extends": "../../jsconfig.base.json",
3
3
  "compilerOptions": {
4
4
  "target": "ES2022",
5
- "types": ["bun"],
6
5
  "strict": false,
7
6
  "exactOptionalPropertyTypes": false,
8
7
  "noImplicitReturns": false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "befly-admin-ui",
3
- "version": "1.8.32",
4
- "gitHead": "ba7fa7cc0534c48de2df4970d69a14853cf6b2a6",
3
+ "version": "1.8.33",
4
+ "gitHead": "84b6e4e0adde7f566cfc890887b024ccf10b0516",
5
5
  "private": false,
6
6
  "description": "Befly - 管理后台功能组件",
7
7
  "keywords": [
@@ -84,17 +84,20 @@
84
84
  </div>
85
85
  </div>
86
86
 
87
- <div v-if="selectedDaysTrend.length > 0" class="trend-content">
88
- <svg viewBox="0 0 100 60" preserveAspectRatio="none" class="trend-svg">
89
- <polyline class="trend-line trend-line-pv" :points="selectedDaysPvPoints" />
90
- <polyline class="trend-line trend-line-uv" :points="selectedDaysUvPoints" />
91
- <circle v-for="item in selectedDaysPvDots" :key="`day-pv-${item.bucketDate}`" class="trend-dot trend-dot-pv" :cx="item.x" :cy="item.y" r="1.2" />
92
- <circle v-for="item in selectedDaysUvDots" :key="`day-uv-${item.bucketDate}`" class="trend-dot trend-dot-uv" :cx="item.x" :cy="item.y" r="1.2" />
93
- </svg>
87
+ <div v-if="selectedDaysHasData" class="trend-content">
88
+ <div class="trend-bars">
89
+ <div v-for="item in selectedDaysBars" :key="item.reportDate" class="trend-bar-group">
90
+ <div class="trend-bar-stack">
91
+ <div class="trend-bar trend-bar-pv" :style="{ height: item.pvHeight }" :title="`${formatReportDate(item.reportDate)} PV ${item.pv}`"></div>
92
+ <div class="trend-bar trend-bar-uv" :style="{ height: item.uvHeight }" :title="`${formatReportDate(item.reportDate)} UV ${item.uv}`"></div>
93
+ </div>
94
+ <div class="trend-bar-label">{{ formatReportDate(item.reportDate) }}</div>
95
+ </div>
96
+ </div>
94
97
 
95
98
  <div class="trend-briefs trend-briefs-wide">
96
- <div v-for="item in selectedDaysTrendPreview" :key="item.bucketDate" class="trend-brief-item">
97
- <div class="trend-date">{{ formatBucketDate(item.bucketDate) }}</div>
99
+ <div v-for="item in selectedDaysTrendPreview" :key="item.reportDate" class="trend-brief-item">
100
+ <div class="trend-date">{{ formatReportDate(item.reportDate) }}</div>
98
101
  <div class="trend-values">
99
102
  <span class="trend-value pv">{{ item.pv }}</span>
100
103
  <span class="trend-value uv">{{ item.uv }}</span>
@@ -291,14 +294,11 @@ const selectedProductTotalUv = computed(() => {
291
294
  return sumTrendField(selectedProduct.value?.days || [], "uv");
292
295
  });
293
296
 
294
- const selectedDaysTrend = computed(() => sortTrendList(selectedProduct.value?.days || [], "bucketDate"));
295
- const selectedDaysTrendMax = computed(() => getTrendMax(selectedDaysTrend.value));
296
- const selectedDaysTrendPreview = computed(() => buildTrendPreview(selectedDaysTrend.value, 10));
297
- const selectedDaysPvPoints = computed(() => buildTrendPoints(selectedDaysTrend.value, "pv", selectedDaysTrendMax.value));
298
- const selectedDaysUvPoints = computed(() => buildTrendPoints(selectedDaysTrend.value, "uv", selectedDaysTrendMax.value));
299
- const selectedDaysPvDots = computed(() => buildTrendDots(selectedDaysTrend.value, "pv", selectedDaysTrendMax.value));
300
- const selectedDaysUvDots = computed(() => buildTrendDots(selectedDaysTrend.value, "uv", selectedDaysTrendMax.value));
297
+ const selectedDaysTrend = computed(() => sortTrendList(selectedProduct.value?.days || [], "reportDate"));
301
298
  const selectedDaysFilled = computed(() => buildFilledTrendDays(selectedProduct.value?.days || [], 30));
299
+ const selectedDaysHasData = computed(() => selectedDaysTrend.value.length > 0);
300
+ const selectedDaysTrendPreview = computed(() => buildTrendPreview(selectedDaysFilled.value, 10));
301
+ const selectedDaysBars = computed(() => buildTrendBars(selectedDaysFilled.value, getTrendMax(selectedDaysFilled.value)));
302
302
  const productPeriodCards = computed(() => {
303
303
  const list = selectedDaysFilled.value;
304
304
  const today = list[list.length - 1] || { pv: 0, uv: 0 };
@@ -317,7 +317,7 @@ const productPeriodCards = computed(() => {
317
317
  });
318
318
 
319
319
  function buildFallbackProduct() {
320
- const days = sortTrendList(realtimeStats.days, "bucketDate");
320
+ const days = sortTrendList(realtimeStats.days, "reportDate");
321
321
 
322
322
  return {
323
323
  key: "all-products",
@@ -334,7 +334,7 @@ function buildFallbackProduct() {
334
334
  };
335
335
  }
336
336
 
337
- function getBucketDate(timestamp) {
337
+ function getReportDateNumber(timestamp) {
338
338
  return Number(
339
339
  new Intl.DateTimeFormat("en-CA", {
340
340
  year: "numeric",
@@ -346,32 +346,32 @@ function getBucketDate(timestamp) {
346
346
  );
347
347
  }
348
348
 
349
- function buildRecentDateList(limit) {
349
+ function buildRecentReportDateList(limit) {
350
350
  const list = [];
351
351
 
352
352
  for (let i = limit - 1; i >= 0; i--) {
353
- list.push(getBucketDate(Date.now() - i * 24 * 60 * 60 * 1000));
353
+ list.push(getReportDateNumber(Date.now() - i * 24 * 60 * 60 * 1000));
354
354
  }
355
355
 
356
356
  return list;
357
357
  }
358
358
 
359
359
  function buildFilledTrendDays(list, limit) {
360
- const dateList = buildRecentDateList(limit);
360
+ const dateList = buildRecentReportDateList(limit);
361
361
  const dataMap = new Map();
362
362
 
363
363
  for (const item of list) {
364
- dataMap.set(Number(item?.bucketDate || 0), {
365
- bucketDate: Number(item?.bucketDate || 0),
364
+ dataMap.set(Number(item?.reportDate || 0), {
365
+ reportDate: Number(item?.reportDate || 0),
366
366
  pv: Number(item?.pv || 0),
367
367
  uv: Number(item?.uv || 0)
368
368
  });
369
369
  }
370
370
 
371
- return dateList.map((bucketDate) => {
371
+ return dateList.map((reportDate) => {
372
372
  return (
373
- dataMap.get(bucketDate) || {
374
- bucketDate: bucketDate,
373
+ dataMap.get(reportDate) || {
374
+ reportDate: reportDate,
375
375
  pv: 0,
376
376
  uv: 0
377
377
  }
@@ -437,49 +437,41 @@ function buildTrendPreview(list, maxCount) {
437
437
  }
438
438
 
439
439
  const preview = [];
440
- const lastIndex = list.length - 1;
441
- const step = Math.ceil(lastIndex / (maxCount - 1));
440
+ const usedIndex = new Set();
442
441
 
443
- for (let i = 0; i <= lastIndex; i += step) {
444
- preview.push(list[i]);
445
- }
442
+ for (let i = 0; i < maxCount; i++) {
443
+ const index = Math.round((i * (list.length - 1)) / (maxCount - 1));
444
+
445
+ if (usedIndex.has(index)) {
446
+ continue;
447
+ }
446
448
 
447
- if (preview[preview.length - 1] !== list[lastIndex]) {
448
- preview.push(list[lastIndex]);
449
+ usedIndex.add(index);
450
+ preview.push(list[index]);
449
451
  }
450
452
 
451
- return preview.slice(0, maxCount);
453
+ return preview;
452
454
  }
453
455
 
454
- function buildTrendDots(list, field, maxValue) {
455
- if (list.length === 0) {
456
- return [];
457
- }
458
-
456
+ function buildTrendBars(list, maxValue) {
459
457
  const result = [];
460
458
 
461
- for (let i = 0; i < list.length; i++) {
462
- const item = list[i];
463
- const value = Number(item?.[field] || 0);
464
- const x = list.length === 1 ? 50 : (i * 100) / (list.length - 1);
465
- const y = 54 - (value / maxValue) * 42;
459
+ for (const item of list) {
460
+ const pv = Number(item?.pv || 0);
461
+ const uv = Number(item?.uv || 0);
466
462
 
467
463
  result.push({
468
- bucketDate: item.bucketDate,
469
- x: Number(x.toFixed(2)),
470
- y: Number(y.toFixed(2))
464
+ reportDate: Number(item?.reportDate || 0),
465
+ pv: pv,
466
+ uv: uv,
467
+ pvHeight: pv > 0 ? `${Math.max((pv / maxValue) * 100, 6)}%` : "0%",
468
+ uvHeight: uv > 0 ? `${Math.max((uv / maxValue) * 100, 6)}%` : "0%"
471
469
  });
472
470
  }
473
471
 
474
472
  return result;
475
473
  }
476
474
 
477
- function buildTrendPoints(list, field, maxValue) {
478
- return buildTrendDots(list, field, maxValue)
479
- .map((item) => `${item.x},${item.y}`)
480
- .join(" ");
481
- }
482
-
483
475
  function getTrendMax(list) {
484
476
  let max = 0;
485
477
 
@@ -527,8 +519,8 @@ function formatDeviceType(deviceType) {
527
519
  return String(deviceType || "Unknown");
528
520
  }
529
521
 
530
- function formatBucketDate(bucketDate) {
531
- const text = String(bucketDate || "");
522
+ function formatReportDate(reportDate) {
523
+ const text = String(reportDate || "");
532
524
 
533
525
  if (text.length !== 8) {
534
526
  return text;
@@ -539,23 +531,23 @@ function formatBucketDate(bucketDate) {
539
531
 
540
532
  const fetchData = async () => {
541
533
  try {
542
- const [overviewRes, visitStatsRes, errorStatsRes] = await Promise.all([$Http("/core/dashboard/systemOverview", {}, [""]), $Http("/core/tongJi/visitStats", {}, [""]), $Http("/core/tongJi/errorStats", {}, [""])]);
534
+ const [overviewRes, onlineStatsRes, infoStatsRes, errorStatsRes] = await Promise.all([$Http("/core/dashboard/systemOverview", {}, [""]), $Http("/core/tongJi/onlineStats", {}, [""]), $Http("/core/tongJi/infoStats", {}, [""]), $Http("/core/tongJi/errorStats", {}, [""])]);
543
535
 
544
536
  Object.assign(permissionStats, overviewRes.data);
545
- realtimeStats.onlineCount = Number(visitStatsRes.data?.onlineCount || 0);
546
- realtimeStats.todayPv = Number(visitStatsRes.data?.today?.pv || 0);
547
- realtimeStats.todayUv = Number(visitStatsRes.data?.today?.uv || 0);
548
- realtimeStats.days = Array.isArray(visitStatsRes.data?.days) ? visitStatsRes.data.days : [];
549
- productState.options = Array.isArray(visitStatsRes.data?.products) ? visitStatsRes.data.products : [];
550
- uaStats.deviceTypes = Array.isArray(visitStatsRes.data?.uaStats?.deviceTypes) ? visitStatsRes.data.uaStats.deviceTypes : [];
551
- uaStats.browsers = Array.isArray(visitStatsRes.data?.uaStats?.browsers) ? visitStatsRes.data.uaStats.browsers : [];
552
- uaStats.browserVersions = Array.isArray(visitStatsRes.data?.uaStats?.browserVersions) ? visitStatsRes.data.uaStats.browserVersions : [];
553
- uaStats.osList = Array.isArray(visitStatsRes.data?.uaStats?.osList) ? visitStatsRes.data.uaStats.osList : [];
554
- uaStats.osVersions = Array.isArray(visitStatsRes.data?.uaStats?.osVersions) ? visitStatsRes.data.uaStats.osVersions : [];
555
- uaStats.deviceVendors = Array.isArray(visitStatsRes.data?.uaStats?.deviceVendors) ? visitStatsRes.data.uaStats.deviceVendors : [];
556
- uaStats.deviceModels = Array.isArray(visitStatsRes.data?.uaStats?.deviceModels) ? visitStatsRes.data.uaStats.deviceModels : [];
557
- uaStats.engines = Array.isArray(visitStatsRes.data?.uaStats?.engines) ? visitStatsRes.data.uaStats.engines : [];
558
- uaStats.cpuArchitectures = Array.isArray(visitStatsRes.data?.uaStats?.cpuArchitectures) ? visitStatsRes.data.uaStats.cpuArchitectures : [];
537
+ realtimeStats.onlineCount = Number(onlineStatsRes.data?.onlineCount || 0);
538
+ realtimeStats.todayPv = Number(onlineStatsRes.data?.today?.pv || 0);
539
+ realtimeStats.todayUv = Number(onlineStatsRes.data?.today?.uv || 0);
540
+ realtimeStats.days = Array.isArray(onlineStatsRes.data?.days) ? onlineStatsRes.data.days : [];
541
+ productState.options = [];
542
+ uaStats.deviceTypes = Array.isArray(infoStatsRes.data?.today?.deviceTypes) ? infoStatsRes.data.today.deviceTypes : [];
543
+ uaStats.browsers = Array.isArray(infoStatsRes.data?.today?.browsers) ? infoStatsRes.data.today.browsers : [];
544
+ uaStats.browserVersions = Array.isArray(infoStatsRes.data?.today?.browserVersions) ? infoStatsRes.data.today.browserVersions : [];
545
+ uaStats.osList = Array.isArray(infoStatsRes.data?.today?.osList) ? infoStatsRes.data.today.osList : [];
546
+ uaStats.osVersions = Array.isArray(infoStatsRes.data?.today?.osVersions) ? infoStatsRes.data.today.osVersions : [];
547
+ uaStats.deviceVendors = Array.isArray(infoStatsRes.data?.today?.deviceVendors) ? infoStatsRes.data.today.deviceVendors : [];
548
+ uaStats.deviceModels = Array.isArray(infoStatsRes.data?.today?.deviceModels) ? infoStatsRes.data.today.deviceModels : [];
549
+ uaStats.engines = Array.isArray(infoStatsRes.data?.today?.engines) ? infoStatsRes.data.today.engines : [];
550
+ uaStats.cpuArchitectures = Array.isArray(infoStatsRes.data?.today?.cpuArchitectures) ? infoStatsRes.data.today.cpuArchitectures : [];
559
551
  errorStats.trend = Array.isArray(errorStatsRes.data?.trend) ? errorStatsRes.data.trend : [];
560
552
  productInfo.productName = String($Config.productName || "-");
561
553
  productInfo.productCode = String($Config.productCode || "-");
@@ -865,10 +857,13 @@ fetchData();
865
857
  background: #2ba471;
866
858
  }
867
859
 
868
- .trend-svg {
869
- width: 100%;
860
+ .trend-bars {
861
+ display: grid;
862
+ grid-template-columns: repeat(30, minmax(0, 1fr));
863
+ align-items: end;
864
+ gap: 0;
870
865
  height: 148px;
871
- display: block;
866
+ padding: 10px 0 6px;
872
867
  border-radius: 8px;
873
868
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.04) 1px, transparent 1px), linear-gradient(to right, rgba(0, 0, 0, 0.03) 1px, transparent 1px), linear-gradient(180deg, rgba(var(--primary-color-rgb), 0.025), rgba(var(--primary-color-rgb), 0.06));
874
869
  background-size:
@@ -877,23 +872,52 @@ fetchData();
877
872
  auto;
878
873
  }
879
874
 
880
- .trend-line {
881
- fill: none;
882
- stroke-width: 1.8;
883
- stroke-linecap: round;
884
- stroke-linejoin: round;
875
+ .trend-bar-group {
876
+ min-width: 0;
877
+ height: 100%;
878
+ display: flex;
879
+ flex-direction: column;
880
+ justify-content: flex-end;
881
+ gap: 4px;
882
+ padding: 0 2px;
883
+ }
884
+
885
+ .trend-bar-stack {
886
+ flex: 1;
887
+ display: flex;
888
+ align-items: flex-end;
889
+ justify-content: center;
890
+ gap: 2px;
891
+ min-height: 0;
892
+ }
893
+
894
+ .trend-bar {
895
+ width: calc(50% - 1px);
896
+ min-width: 2px;
897
+ border-radius: 4px 4px 0 0;
898
+ transition: all 0.2s ease;
899
+
900
+ &:hover {
901
+ opacity: 0.9;
902
+ transform: translateY(-1px);
903
+ }
885
904
  }
886
905
 
887
- .trend-line-pv,
888
- .trend-dot-pv {
889
- stroke: #0052d9;
890
- fill: #0052d9;
906
+ .trend-bar-pv {
907
+ background: linear-gradient(180deg, rgba(0, 82, 217, 0.65), #0052d9);
891
908
  }
892
909
 
893
- .trend-line-uv,
894
- .trend-dot-uv {
895
- stroke: #2ba471;
896
- fill: #2ba471;
910
+ .trend-bar-uv {
911
+ background: linear-gradient(180deg, rgba(43, 164, 113, 0.65), #2ba471);
912
+ }
913
+
914
+ .trend-bar-label {
915
+ font-size: 10px;
916
+ line-height: 1;
917
+ color: var(--text-tertiary);
918
+ text-align: center;
919
+ font-variant-numeric: tabular-nums;
920
+ transform: scale(0.92);
897
921
  }
898
922
 
899
923
  .trend-briefs {
@@ -1040,6 +1064,16 @@ fetchData();
1040
1064
  grid-template-columns: repeat(6, minmax(0, 1fr));
1041
1065
  }
1042
1066
 
1067
+ .trend-bars {
1068
+ grid-template-columns: repeat(15, minmax(0, 1fr));
1069
+ row-gap: 6px;
1070
+ height: auto;
1071
+ }
1072
+
1073
+ .trend-bar-group:nth-child(n + 16) {
1074
+ display: none;
1075
+ }
1076
+
1043
1077
  .ua-grid {
1044
1078
  grid-template-columns: repeat(2, minmax(0, 1fr));
1045
1079
  }
@@ -1073,6 +1107,14 @@ fetchData();
1073
1107
  .trend-briefs-wide {
1074
1108
  grid-template-columns: repeat(4, minmax(0, 1fr));
1075
1109
  }
1110
+
1111
+ .trend-bars {
1112
+ grid-template-columns: repeat(10, minmax(0, 1fr));
1113
+ }
1114
+
1115
+ .trend-bar-group:nth-child(n + 11) {
1116
+ display: none;
1117
+ }
1076
1118
  }
1077
1119
 
1078
1120
  @media (max-width: 640px) {
@@ -1090,8 +1132,13 @@ fetchData();
1090
1132
  width: 100%;
1091
1133
  }
1092
1134
 
1093
- .trend-svg {
1135
+ .trend-bars {
1136
+ grid-template-columns: repeat(7, minmax(0, 1fr));
1094
1137
  height: 126px;
1095
1138
  }
1139
+
1140
+ .trend-bar-group:nth-child(n + 8) {
1141
+ display: none;
1142
+ }
1096
1143
  }
1097
1144
  </style>
@@ -268,8 +268,18 @@ function formatDeviceType(deviceType) {
268
268
  }
269
269
 
270
270
  function formatTime(timestamp) {
271
- if (!timestamp) return "-";
272
- const date = new Date(timestamp);
271
+ const value = Number(timestamp || 0);
272
+
273
+ if (!value) {
274
+ return "-";
275
+ }
276
+
277
+ const date = new Date(value);
278
+
279
+ if (Number.isNaN(date.getTime())) {
280
+ return "-";
281
+ }
282
+
273
283
  const year = date.getFullYear();
274
284
  const month = String(date.getMonth() + 1).padStart(2, "0");
275
285
  const day = String(date.getDate()).padStart(2, "0");
@@ -1,700 +0,0 @@
1
- <template>
2
- <div class="page-visit-stats">
3
- <div class="page-header">
4
- <div>
5
- <div class="page-title">访问统计</div>
6
- <div class="page-desc">查看访问趋势、在线人数与 UA 设备明细分布</div>
7
- </div>
8
- <TButton theme="primary" @click="fetchData">刷新统计</TButton>
9
- </div>
10
-
11
- <div class="summary-grid">
12
- <div class="summary-card">
13
- <div class="summary-label">产品名称</div>
14
- <div class="summary-value primary">{{ $Config.productName }}</div>
15
- </div>
16
- <div class="summary-card">
17
- <div class="summary-label">产品代号</div>
18
- <div class="summary-value info">{{ $Config.productCode }}</div>
19
- </div>
20
- <div class="summary-card">
21
- <div class="summary-label">产品版本</div>
22
- <div class="summary-value success">{{ $Config.productVersion }}</div>
23
- </div>
24
- <div class="summary-card">
25
- <div class="summary-label">当前在线</div>
26
- <div class="summary-value primary">{{ $Data.onlineCount }}</div>
27
- </div>
28
- <div class="summary-card">
29
- <div class="summary-label">今日访问</div>
30
- <div class="summary-value info">{{ $Data.todayPv }}</div>
31
- </div>
32
- <div class="summary-card">
33
- <div class="summary-label">今日人数</div>
34
- <div class="summary-value success">{{ $Data.todayUv }}</div>
35
- </div>
36
- <div class="summary-card">
37
- <div class="summary-label">主设备类型</div>
38
- <div class="summary-value warning">{{ getTopUaLabel($Data.uaStats.deviceTypes, formatDeviceType) }}</div>
39
- </div>
40
- <div class="summary-card">
41
- <div class="summary-label">主浏览器</div>
42
- <div class="summary-value primary">{{ getTopUaLabel($Data.uaStats.browsers) }}</div>
43
- </div>
44
- <div class="summary-card">
45
- <div class="summary-label">主系统</div>
46
- <div class="summary-value danger">{{ getTopUaLabel($Data.uaStats.osList) }}</div>
47
- </div>
48
- </div>
49
-
50
- <div class="trend-grid">
51
- <div class="trend-card">
52
- <div class="card-title">今日 30 分钟访问趋势</div>
53
- <div v-if="todayTrend.length > 0" class="trend-chart-wrap">
54
- <svg viewBox="0 0 100 60" preserveAspectRatio="none" class="trend-svg">
55
- <polyline class="trend-line trend-line-pv" :points="todayPvPoints" />
56
- <polyline class="trend-line trend-line-uv" :points="todayUvPoints" />
57
- <circle v-for="item in todayPvDots" :key="`pv-${item.bucketTime}`" class="trend-dot trend-dot-pv" :cx="item.x" :cy="item.y" r="1.2" />
58
- <circle v-for="item in todayUvDots" :key="`uv-${item.bucketTime}`" class="trend-dot trend-dot-uv" :cx="item.x" :cy="item.y" r="1.2" />
59
- </svg>
60
- <div class="trend-mini-list">
61
- <div v-for="item in todayTrendPreview" :key="`preview-${item.bucketTime}`" class="trend-mini-item">
62
- <div class="trend-mini-time">{{ formatBucketTime(item.bucketTime) }}</div>
63
- <div class="trend-mini-values">
64
- <span class="pv">PV {{ item.pv }}</span>
65
- <span class="uv">UV {{ item.uv }}</span>
66
- </div>
67
- </div>
68
- </div>
69
- </div>
70
- <div v-else class="empty-block">暂无今日趋势数据</div>
71
- </div>
72
-
73
- <div class="trend-card">
74
- <div class="card-title">近 7 天访问趋势</div>
75
- <div v-if="trendDays.length > 0" class="trend-chart-wrap">
76
- <svg viewBox="0 0 100 60" preserveAspectRatio="none" class="trend-svg">
77
- <polyline class="trend-line trend-line-pv" :points="pvPoints" />
78
- <polyline class="trend-line trend-line-uv" :points="uvPoints" />
79
- <circle v-for="item in pvDots" :key="`days-pv-${item.bucketDate}`" class="trend-dot trend-dot-pv" :cx="item.x" :cy="item.y" r="1.2" />
80
- <circle v-for="item in uvDots" :key="`days-uv-${item.bucketDate}`" class="trend-dot trend-dot-uv" :cx="item.x" :cy="item.y" r="1.2" />
81
- </svg>
82
- <div class="trend-mini-list trend-mini-list-days">
83
- <div v-for="item in trendDays" :key="`day-${item.bucketDate}`" class="trend-mini-item">
84
- <div class="trend-mini-time">{{ formatBucketDate(item.bucketDate) }}</div>
85
- <div class="trend-mini-values">
86
- <span class="pv">PV {{ item.pv }}</span>
87
- <span class="uv">UV {{ item.uv }}</span>
88
- </div>
89
- </div>
90
- </div>
91
- </div>
92
- <div v-else class="empty-block">暂无近 7 天趋势数据</div>
93
- </div>
94
- </div>
95
-
96
- <div class="ua-grid">
97
- <div class="ua-card">
98
- <div class="card-title">设备类型</div>
99
- <div v-if="$Data.uaStats.deviceTypes.length > 0" class="ua-list">
100
- <div v-for="item in $Data.uaStats.deviceTypes" :key="`type-${item.name}`" class="ua-row">
101
- <span>{{ formatDeviceType(item.name) }}</span>
102
- <strong>{{ item.count }}</strong>
103
- </div>
104
- </div>
105
- <div v-else class="empty-inline">暂无设备类型数据</div>
106
- </div>
107
-
108
- <div class="ua-card">
109
- <div class="card-title">浏览器</div>
110
- <div v-if="$Data.uaStats.browsers.length > 0" class="ua-list">
111
- <div v-for="item in $Data.uaStats.browsers" :key="`browser-${item.name}`" class="ua-row">
112
- <span>{{ item.name }}</span>
113
- <strong>{{ item.count }}</strong>
114
- </div>
115
- </div>
116
- <div v-else class="empty-inline">暂无浏览器数据</div>
117
- </div>
118
-
119
- <div class="ua-card">
120
- <div class="card-title">操作系统</div>
121
- <div v-if="$Data.uaStats.osList.length > 0" class="ua-list">
122
- <div v-for="item in $Data.uaStats.osList" :key="`os-${item.name}`" class="ua-row">
123
- <span>{{ item.name }}</span>
124
- <strong>{{ item.count }}</strong>
125
- </div>
126
- </div>
127
- <div v-else class="empty-inline">暂无操作系统数据</div>
128
- </div>
129
-
130
- <div class="ua-card">
131
- <div class="card-title">浏览器版本</div>
132
- <div v-if="$Data.uaStats.browserVersions.length > 0" class="ua-chip-list">
133
- <span v-for="item in $Data.uaStats.browserVersions" :key="`browser-version-${item.name}`" class="ua-chip">{{ item.name }} · {{ item.count }}</span>
134
- </div>
135
- <div v-else class="empty-inline">暂无浏览器版本数据</div>
136
- </div>
137
-
138
- <div class="ua-card">
139
- <div class="card-title">系统版本</div>
140
- <div v-if="$Data.uaStats.osVersions.length > 0" class="ua-chip-list">
141
- <span v-for="item in $Data.uaStats.osVersions" :key="`os-version-${item.name}`" class="ua-chip">{{ item.name }} · {{ item.count }}</span>
142
- </div>
143
- <div v-else class="empty-inline">暂无系统版本数据</div>
144
- </div>
145
-
146
- <div class="ua-card">
147
- <div class="card-title">设备厂商</div>
148
- <div v-if="$Data.uaStats.deviceVendors.length > 0" class="ua-chip-list">
149
- <span v-for="item in $Data.uaStats.deviceVendors" :key="`vendor-${item.name}`" class="ua-chip">{{ item.name }} · {{ item.count }}</span>
150
- </div>
151
- <div v-else class="empty-inline">暂无设备厂商数据</div>
152
- </div>
153
-
154
- <div class="ua-card ua-card-wide">
155
- <div class="card-title">设备明细</div>
156
- <div class="detail-grid">
157
- <div class="detail-block">
158
- <div class="detail-title">设备型号</div>
159
- <div v-if="$Data.uaStats.deviceModels.length > 0" class="ua-chip-list">
160
- <span v-for="item in $Data.uaStats.deviceModels" :key="`model-${item.name}`" class="ua-chip">{{ item.name }} · {{ item.count }}</span>
161
- </div>
162
- <div v-else class="empty-inline">暂无设备型号数据</div>
163
- </div>
164
-
165
- <div class="detail-block">
166
- <div class="detail-title">渲染引擎</div>
167
- <div v-if="$Data.uaStats.engines.length > 0" class="ua-chip-list">
168
- <span v-for="item in $Data.uaStats.engines" :key="`engine-${item.name}`" class="ua-chip">{{ item.name }} · {{ item.count }}</span>
169
- </div>
170
- <div v-else class="empty-inline">暂无渲染引擎数据</div>
171
- </div>
172
-
173
- <div class="detail-block">
174
- <div class="detail-title">CPU 架构</div>
175
- <div v-if="$Data.uaStats.cpuArchitectures.length > 0" class="ua-chip-list">
176
- <span v-for="item in $Data.uaStats.cpuArchitectures" :key="`cpu-${item.name}`" class="ua-chip">{{ item.name }} · {{ item.count }}</span>
177
- </div>
178
- <div v-else class="empty-inline">暂无 CPU 架构数据</div>
179
- </div>
180
- </div>
181
- </div>
182
- </div>
183
- </div>
184
- </template>
185
-
186
- <script setup>
187
- import { computed, reactive } from "vue";
188
- import { Button as TButton } from "tdesign-vue-next";
189
- import { $Http } from "@/plugins/http";
190
- import { $Config } from "@/plugins/config";
191
-
192
- const $Data = reactive({
193
- onlineCount: 0,
194
- todayPv: 0,
195
- todayUv: 0,
196
- days: [],
197
- trend: [],
198
- uaStats: {
199
- deviceTypes: [],
200
- browsers: [],
201
- browserVersions: [],
202
- osList: [],
203
- osVersions: [],
204
- deviceVendors: [],
205
- deviceModels: [],
206
- engines: [],
207
- cpuArchitectures: []
208
- }
209
- });
210
-
211
- function getRecentDays(list, dayCount = 7) {
212
- const map = new Map();
213
- const now = new Date();
214
-
215
- for (const item of Array.isArray(list) ? list : []) {
216
- const bucketDate = Number(item.bucketDate || 0);
217
- if (!bucketDate) {
218
- continue;
219
- }
220
-
221
- map.set(bucketDate, {
222
- bucketDate: bucketDate,
223
- pv: Number(item.pv || 0),
224
- uv: Number(item.uv || 0)
225
- });
226
- }
227
-
228
- const days = [];
229
- for (let i = dayCount - 1; i >= 0; i--) {
230
- const date = new Date(now);
231
- date.setHours(0, 0, 0, 0);
232
- date.setDate(date.getDate() - i);
233
- const bucketDate = Number(`${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`);
234
-
235
- days.push(
236
- map.get(bucketDate) || {
237
- bucketDate: bucketDate,
238
- pv: 0,
239
- uv: 0
240
- }
241
- );
242
- }
243
-
244
- return days;
245
- }
246
-
247
- const todayTrend = computed(() => {
248
- const list = Array.isArray($Data.trend) ? $Data.trend.slice() : [];
249
- list.sort((a, b) => Number(a.bucketTime || 0) - Number(b.bucketTime || 0));
250
- return list;
251
- });
252
-
253
- const trendDays = computed(() => {
254
- return getRecentDays($Data.days);
255
- });
256
-
257
- const todayTrendPreview = computed(() => {
258
- const list = todayTrend.value;
259
- if (list.length <= 6) {
260
- return list;
261
- }
262
-
263
- const preview = [];
264
- const lastIndex = list.length - 1;
265
- const step = Math.ceil(lastIndex / 5);
266
-
267
- for (let i = 0; i <= lastIndex; i += step) {
268
- preview.push(list[i]);
269
- }
270
-
271
- if (preview[preview.length - 1]?.bucketTime !== list[lastIndex].bucketTime) {
272
- preview.push(list[lastIndex]);
273
- }
274
-
275
- return preview;
276
- });
277
-
278
- const todayTrendMax = computed(() => getTrendMax(todayTrend.value));
279
- const trendMax = computed(() => getTrendMax(trendDays.value));
280
-
281
- const todayPvPoints = computed(() => buildTrendPoints(todayTrend.value, "pv", "bucketTime", todayTrendMax.value));
282
- const todayUvPoints = computed(() => buildTrendPoints(todayTrend.value, "uv", "bucketTime", todayTrendMax.value));
283
- const todayPvDots = computed(() => buildTrendDots(todayTrend.value, "pv", "bucketTime", todayTrendMax.value));
284
- const todayUvDots = computed(() => buildTrendDots(todayTrend.value, "uv", "bucketTime", todayTrendMax.value));
285
- const pvPoints = computed(() => buildTrendPoints(trendDays.value, "pv", "bucketDate", trendMax.value));
286
- const uvPoints = computed(() => buildTrendPoints(trendDays.value, "uv", "bucketDate", trendMax.value));
287
- const pvDots = computed(() => buildTrendDots(trendDays.value, "pv", "bucketDate", trendMax.value));
288
- const uvDots = computed(() => buildTrendDots(trendDays.value, "uv", "bucketDate", trendMax.value));
289
-
290
- function buildTrendDots(list, field, keyField, maxValue) {
291
- if (list.length === 0) {
292
- return [];
293
- }
294
-
295
- const dots = [];
296
- for (let i = 0; i < list.length; i++) {
297
- const item = list[i];
298
- const value = Number(item[field] || 0);
299
- const x = list.length === 1 ? 50 : (i * 100) / (list.length - 1);
300
- const y = 54 - (value / maxValue) * 42;
301
-
302
- dots.push({
303
- bucketDate: item.bucketDate,
304
- bucketTime: item.bucketTime,
305
- key: item[keyField],
306
- x: Number(x.toFixed(2)),
307
- y: Number(y.toFixed(2))
308
- });
309
- }
310
-
311
- return dots;
312
- }
313
-
314
- function buildTrendPoints(list, field, keyField, maxValue) {
315
- const dots = buildTrendDots(list, field, keyField, maxValue);
316
- return dots.map((item) => `${item.x},${item.y}`).join(" ");
317
- }
318
-
319
- function getTrendMax(list, fieldA = "pv", fieldB = "uv") {
320
- let max = 0;
321
-
322
- for (const item of list) {
323
- const valueA = Number(item[fieldA] || 0);
324
- const valueB = Number(item[fieldB] || 0);
325
-
326
- if (valueA > max) {
327
- max = valueA;
328
- }
329
-
330
- if (valueB > max) {
331
- max = valueB;
332
- }
333
- }
334
-
335
- return max > 0 ? max : 1;
336
- }
337
-
338
- function formatBucketDate(bucketDate) {
339
- const text = String(bucketDate || "");
340
- if (text.length !== 8) {
341
- return text;
342
- }
343
-
344
- return `${text.slice(4, 6)}-${text.slice(6, 8)}`;
345
- }
346
-
347
- function formatBucketTime(bucketTime) {
348
- const date = new Date(Number(bucketTime || 0));
349
- if (Number.isNaN(date.getTime())) {
350
- return "--:--";
351
- }
352
-
353
- return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
354
- }
355
-
356
- function formatDeviceType(deviceType) {
357
- if (deviceType === "desktop") {
358
- return "桌面端";
359
- }
360
-
361
- if (deviceType === "mobile") {
362
- return "移动端";
363
- }
364
-
365
- if (deviceType === "tablet") {
366
- return "平板";
367
- }
368
-
369
- if (deviceType === "smarttv") {
370
- return "电视";
371
- }
372
-
373
- if (deviceType === "wearable") {
374
- return "穿戴设备";
375
- }
376
-
377
- if (deviceType === "embedded") {
378
- return "嵌入式设备";
379
- }
380
-
381
- return String(deviceType || "Unknown");
382
- }
383
-
384
- function getTopUaLabel(list, formatter) {
385
- const top = Array.isArray(list) && list.length > 0 ? list[0] : null;
386
- if (!top) {
387
- return "-";
388
- }
389
-
390
- if (typeof formatter === "function") {
391
- return formatter(top.name);
392
- }
393
-
394
- return top.name;
395
- }
396
-
397
- async function fetchData() {
398
- try {
399
- const result = await $Http("/core/tongJi/visitStats", {}, [""]);
400
- $Data.onlineCount = Number(result.data?.onlineCount || 0);
401
- $Data.todayPv = Number(result.data?.today?.pv || 0);
402
- $Data.todayUv = Number(result.data?.today?.uv || 0);
403
- $Data.days = Array.isArray(result.data?.days) ? result.data.days : [];
404
- $Data.trend = Array.isArray(result.data?.trend) ? result.data.trend : [];
405
- $Data.uaStats.deviceTypes = Array.isArray(result.data?.uaStats?.deviceTypes) ? result.data.uaStats.deviceTypes : [];
406
- $Data.uaStats.browsers = Array.isArray(result.data?.uaStats?.browsers) ? result.data.uaStats.browsers : [];
407
- $Data.uaStats.browserVersions = Array.isArray(result.data?.uaStats?.browserVersions) ? result.data.uaStats.browserVersions : [];
408
- $Data.uaStats.osList = Array.isArray(result.data?.uaStats?.osList) ? result.data.uaStats.osList : [];
409
- $Data.uaStats.osVersions = Array.isArray(result.data?.uaStats?.osVersions) ? result.data.uaStats.osVersions : [];
410
- $Data.uaStats.deviceVendors = Array.isArray(result.data?.uaStats?.deviceVendors) ? result.data.uaStats.deviceVendors : [];
411
- $Data.uaStats.deviceModels = Array.isArray(result.data?.uaStats?.deviceModels) ? result.data.uaStats.deviceModels : [];
412
- $Data.uaStats.engines = Array.isArray(result.data?.uaStats?.engines) ? result.data.uaStats.engines : [];
413
- $Data.uaStats.cpuArchitectures = Array.isArray(result.data?.uaStats?.cpuArchitectures) ? result.data.uaStats.cpuArchitectures : [];
414
- } catch (_error) {
415
- // 静默失败
416
- }
417
- }
418
-
419
- fetchData();
420
- </script>
421
-
422
- <style scoped lang="scss">
423
- .page-visit-stats {
424
- display: flex;
425
- flex-direction: column;
426
- gap: 16px;
427
- }
428
-
429
- .page-header {
430
- display: flex;
431
- align-items: center;
432
- justify-content: space-between;
433
- gap: 12px;
434
- }
435
-
436
- .page-title {
437
- font-size: 22px;
438
- font-weight: 700;
439
- color: var(--text-primary);
440
- }
441
-
442
- .page-desc {
443
- margin-top: 4px;
444
- font-size: 13px;
445
- color: var(--text-secondary);
446
- }
447
-
448
- .summary-grid {
449
- display: grid;
450
- grid-template-columns: repeat(6, minmax(0, 1fr));
451
- gap: 12px;
452
- }
453
-
454
- .summary-card,
455
- .trend-card,
456
- .ua-card {
457
- border: 1px solid var(--border-color);
458
- border-radius: 8px;
459
- background: linear-gradient(180deg, rgba(var(--primary-color-rgb), 0.03), white);
460
- padding: 14px;
461
- }
462
-
463
- .summary-label {
464
- font-size: 13px;
465
- color: var(--text-secondary);
466
- margin-bottom: 6px;
467
- }
468
-
469
- .summary-value {
470
- font-size: 24px;
471
- font-weight: 700;
472
- color: var(--text-primary);
473
-
474
- &.primary {
475
- color: var(--primary-color);
476
- }
477
-
478
- &.info {
479
- color: var(--brand-color);
480
- }
481
-
482
- &.success {
483
- color: var(--success-color);
484
- }
485
-
486
- &.warning {
487
- color: var(--warning-color);
488
- }
489
-
490
- &.danger {
491
- color: var(--error-color);
492
- }
493
- }
494
-
495
- .trend-grid {
496
- display: grid;
497
- grid-template-columns: repeat(2, minmax(0, 1fr));
498
- gap: 12px;
499
- }
500
-
501
- .card-title {
502
- font-size: 14px;
503
- font-weight: 600;
504
- color: var(--text-primary);
505
- margin-bottom: 10px;
506
- }
507
-
508
- .trend-chart-wrap {
509
- display: flex;
510
- flex-direction: column;
511
- gap: 10px;
512
- }
513
-
514
- .trend-svg {
515
- width: 100%;
516
- height: 220px;
517
- display: block;
518
- border-radius: 8px;
519
- background: linear-gradient(to bottom, rgba(0, 0, 0, 0.04) 1px, transparent 1px), linear-gradient(to right, rgba(0, 0, 0, 0.03) 1px, transparent 1px), linear-gradient(180deg, rgba(var(--primary-color-rgb), 0.02), rgba(var(--primary-color-rgb), 0.05));
520
- background-size:
521
- 100% 25%,
522
- 14.285% 100%,
523
- auto;
524
- }
525
-
526
- .trend-line {
527
- fill: none;
528
- stroke-width: 1.8;
529
- stroke-linecap: round;
530
- stroke-linejoin: round;
531
- }
532
-
533
- .trend-line-pv {
534
- stroke: #0052d9;
535
- }
536
-
537
- .trend-line-uv {
538
- stroke: #2ba471;
539
- }
540
-
541
- .trend-dot-pv {
542
- fill: #0052d9;
543
- }
544
-
545
- .trend-dot-uv {
546
- fill: #2ba471;
547
- }
548
-
549
- .trend-mini-list {
550
- display: grid;
551
- grid-template-columns: repeat(6, minmax(0, 1fr));
552
- gap: 8px;
553
- }
554
-
555
- .trend-mini-list-days {
556
- grid-template-columns: repeat(7, minmax(0, 1fr));
557
- }
558
-
559
- .trend-mini-item {
560
- padding: 8px 6px;
561
- border-radius: 6px;
562
- background: rgba(255, 255, 255, 0.85);
563
- border: 1px solid rgba(0, 0, 0, 0.05);
564
- text-align: center;
565
- }
566
-
567
- .trend-mini-time {
568
- font-size: 12px;
569
- color: var(--text-secondary);
570
- margin-bottom: 4px;
571
- }
572
-
573
- .trend-mini-values {
574
- display: flex;
575
- flex-direction: column;
576
- gap: 2px;
577
- font-size: 12px;
578
- font-weight: 600;
579
- }
580
-
581
- .trend-mini-values .pv {
582
- color: #0052d9;
583
- }
584
-
585
- .trend-mini-values .uv {
586
- color: #2ba471;
587
- }
588
-
589
- .ua-grid {
590
- display: grid;
591
- grid-template-columns: repeat(3, minmax(0, 1fr));
592
- gap: 12px;
593
- }
594
-
595
- .ua-card-wide {
596
- grid-column: span 3;
597
- }
598
-
599
- .ua-list {
600
- display: flex;
601
- flex-direction: column;
602
- gap: 8px;
603
- }
604
-
605
- .ua-row {
606
- display: flex;
607
- align-items: center;
608
- justify-content: space-between;
609
- gap: 12px;
610
- padding: 8px 10px;
611
- border-radius: 6px;
612
- background: rgba(var(--primary-color-rgb), 0.04);
613
- }
614
-
615
- .ua-chip-list {
616
- display: flex;
617
- flex-wrap: wrap;
618
- gap: 8px;
619
- }
620
-
621
- .ua-chip {
622
- display: inline-flex;
623
- align-items: center;
624
- padding: 4px 10px;
625
- border-radius: 999px;
626
- background: rgba(var(--primary-color-rgb), 0.08);
627
- color: var(--primary-color);
628
- font-size: 12px;
629
- font-weight: 600;
630
- }
631
-
632
- .detail-grid {
633
- display: grid;
634
- grid-template-columns: repeat(3, minmax(0, 1fr));
635
- gap: 12px;
636
- }
637
-
638
- .detail-block {
639
- border-radius: 6px;
640
- background: rgba(var(--primary-color-rgb), 0.03);
641
- padding: 10px;
642
- }
643
-
644
- .detail-title {
645
- font-size: 13px;
646
- font-weight: 600;
647
- color: var(--text-primary);
648
- margin-bottom: 8px;
649
- }
650
-
651
- .empty-block,
652
- .empty-inline {
653
- display: flex;
654
- align-items: center;
655
- justify-content: center;
656
- min-height: 96px;
657
- border-radius: 6px;
658
- background: rgba(var(--primary-color-rgb), 0.03);
659
- color: var(--text-secondary);
660
- font-size: 13px;
661
- }
662
-
663
- .empty-inline {
664
- min-height: 72px;
665
- }
666
-
667
- @media (max-width: 1200px) {
668
- .summary-grid {
669
- grid-template-columns: repeat(3, minmax(0, 1fr));
670
- }
671
- }
672
-
673
- @media (max-width: 960px) {
674
- .trend-grid,
675
- .ua-grid,
676
- .detail-grid {
677
- grid-template-columns: 1fr;
678
- }
679
-
680
- .ua-card-wide {
681
- grid-column: span 1;
682
- }
683
- }
684
-
685
- @media (max-width: 640px) {
686
- .page-header {
687
- flex-direction: column;
688
- align-items: flex-start;
689
- }
690
-
691
- .summary-grid {
692
- grid-template-columns: 1fr;
693
- }
694
-
695
- .trend-mini-list,
696
- .trend-mini-list-days {
697
- grid-template-columns: repeat(2, minmax(0, 1fr));
698
- }
699
- }
700
- </style>