Files
hashbro 8d5ec411f1 feat(analytics): 数据分析页增加访问与受控设备的国家维度分布
参考受控版本分布的饼图实现,新增国家维度分布:
- AnalyticsDailyDim 增加 KIND_VISIT_COUNTRY / KIND_DEVICE_COUNTRY 常量
- AnalyticsReportService::aggregateDay() 按 country 聚合访问 UV 与受控设备数
- present() 汇总并返回 by_visit_country / by_device_country(ISO 代码转中文名展示)
- 视图新增两个国家饼图卡片与两个国家明细标签区

性能与正确性修复:
- ensureCached() 改为一次查询校验所有按日聚合 kind 的缓存完整性,
  避免旧缓存完整时新维度不被聚合的问题
- 新增迁移为 page_visits / devices 建立 (created_at, country) 复合索引,
  对齐 os_version 维度的索引做法,加速按日期范围 + GROUP BY country 的聚合
2026-09-26 19:49:25 +00:00

431 lines
15 KiB
PHP
Raw Permalink 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.
@extends('admin.content')
@section('title', '数据分析')
@section('content')
@php $portal = $portal ?? 'admin'; @endphp
<style>
.an-filter .layui-form-item { margin-bottom: 0; }
.an-filter .layui-inline {
margin-right: 10px;
margin-bottom: 0;
}
.an-filter .layui-form-label {
width: 72px;
padding: 9px 10px 9px 0;
text-align: left;
box-sizing: border-box;
}
.an-filter .layui-input-block {
margin-left: 72px;
min-height: 36px;
}
.an-filter .an-date-input { width: 210px; }
.an-filter .an-date-shortcuts {
margin-left: 0;
margin-right: 4px;
}
.an-filter .an-date-shortcuts .layui-btn {
margin: 0 4px 0 0;
height: 36px;
line-height: 36px;
padding: 0 12px;
}
.an-filter .an-agent-block {
width: 150px;
min-width: 150px !important;
}
.an-survival {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px 16px;
}
.an-metric { font-size: 26px; line-height: 1.2; color: #009688; font-weight: 600; }
.an-metric-caption { font-size: 13px; color: #999; }
.an-pie-card .layui-card-body { padding-bottom: 8px; }
.an-pie { width: 100%; height: 240px; }
.an-pie-hint {
margin: 0 0 4px;
text-align: center;
font-size: 12px;
color: #bbb;
min-height: 18px;
}
.an-tags { line-height: 26px; }
.an-tag {
display: inline-block;
margin: 0 8px 8px 0;
padding: 0 8px;
font-size: 12px;
line-height: 22px;
color: #cf8a00;
background: #fff7e6;
border: 1px solid #ffe0a3;
border-radius: 3px;
vertical-align: middle;
}
.an-tags-empty { color: #bbb; font-size: 13px; }
</style>
<div class="layui-card">
<form class="layui-form layui-card-header layuiadmin-card-header-auto an-filter" lay-filter="LAY-an-search">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">时间</label>
<div class="layui-input-block an-date-input">
<input type="text" name="date_range" id="LAY-an-date-range" placeholder="开始日期 - 结束日期" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline an-date-shortcuts">
<button type="button" class="layui-btn layui-btn-primary" data-range="today">今日</button>
<button type="button" class="layui-btn layui-btn-primary" data-range="yesterday">昨日</button>
<button type="button" class="layui-btn layui-btn-primary" data-range="7d">近 7 天</button>
<button type="button" class="layui-btn layui-btn-primary" data-range="30d">近 30 天</button>
<button type="button" class="layui-btn layui-btn-primary" data-range="month">本月</button>
</div>
@include('admin.partials.filter_agent_select', ['agentBlockClass' => 'an-agent-block'])
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="LAY-an-search">查询</button>
</div>
</div>
</form>
</div>
<div class="layui-row layui-col-space15">
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">设备存活时长均值</div>
<div class="layui-card-body">
<div class="an-survival">
<div class="an-metric" id="an-survival">—</div>
<span class="an-metric-caption" id="an-survival-caption">区间内新增设备 · 创建到最后更新</span>
</div>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card">
<div class="layui-card-header">有钱包设备存活时长均值</div>
<div class="layui-card-body">
<div class="an-survival">
<div class="an-metric" id="an-survival-wallet">—</div>
<span class="an-metric-caption" id="an-survival-wallet-caption">区间内新增有钱包设备 · 创建到最后更新</span>
</div>
</div>
</div>
</div>
</div>
<div class="layui-row layui-col-space15">
<div class="layui-col-md4">
<div class="layui-card an-pie-card">
<div class="layui-card-header">访问 · 按系统</div>
<div class="layui-card-body">
<div id="an-os-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-os-hint"></p>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card an-pie-card">
<div class="layui-card-header">访问 · iOS 版本</div>
<div class="layui-card-body">
<div id="an-ios-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-ios-hint"></p>
</div>
</div>
</div>
<div class="layui-col-md4">
<div class="layui-card an-pie-card">
<div class="layui-card-header">受控设备 · 版本</div>
<div class="layui-card-body">
<div id="an-device-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-device-hint"></p>
</div>
</div>
</div>
</div>
<div class="layui-row layui-col-space15">
<div class="layui-col-md6">
<div class="layui-card an-pie-card">
<div class="layui-card-header">访问 · 国家</div>
<div class="layui-card-body">
<div id="an-visit-country-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-visit-country-hint"></p>
</div>
</div>
</div>
<div class="layui-col-md6">
<div class="layui-card an-pie-card">
<div class="layui-card-header">受控设备 · 国家</div>
<div class="layui-card-body">
<div id="an-device-country-chart" class="an-pie"></div>
<p class="an-pie-hint" id="an-device-country-hint"></p>
</div>
</div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">受控设备版本明细 <span class="layui-word-aux" id="an-device-count"></span></div>
<div class="layui-card-body">
<div class="an-tags" id="an-by-device-ios"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">访问版本明细 <span class="layui-word-aux" id="an-ios-count"></span></div>
<div class="layui-card-body">
<div class="an-tags" id="an-by-ios-version"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">访问国家明细 <span class="layui-word-aux" id="an-visit-country-count"></span></div>
<div class="layui-card-body">
<div class="an-tags" id="an-by-visit-country"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-card">
<div class="layui-card-header">受控设备国家明细 <span class="layui-word-aux" id="an-device-country-count"></span></div>
<div class="layui-card-body">
<div class="an-tags" id="an-by-device-country"><span class="an-tags-empty">—</span></div>
</div>
</div>
<div class="layui-word-aux" id="an-range" style="margin:0 0 12px;"></div>
@endsection
@push('scripts')
<script>
var adminStatic = @json(rtrim(asset('static/layuiadmin'), '/').'/');
layui.extend({
echarts: '{/}' + adminStatic + 'lib/extend/echarts',
echartsTheme: '{/}' + adminStatic + 'lib/extend/echartsTheme'
});
layui.use(['form', 'laydate', 'layer', 'echarts'], function () {
var form = layui.form;
var laydate = layui.laydate;
var layer = layui.layer;
var $ = layui.$;
var echartsLib = layui.echarts;
var dataUrl = @json(route(($portal ?? 'admin').'.reports.analytics.data'));
var charts = {};
var PIE_MAX = 7;
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function fmtDate(d) {
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
}
function rangeOf(kind) {
var today = new Date();
today.setHours(0, 0, 0, 0);
if (kind === 'today') return fmtDate(today) + ' - ' + fmtDate(today);
if (kind === 'yesterday') {
var y = new Date(today);
y.setDate(y.getDate() - 1);
return fmtDate(y) + ' - ' + fmtDate(y);
}
if (kind === '7d') {
var from7 = new Date(today);
from7.setDate(today.getDate() - 6);
return fmtDate(from7) + ' - ' + fmtDate(today);
}
if (kind === '30d') {
var from30 = new Date(today);
from30.setDate(today.getDate() - 29);
return fmtDate(from30) + ' - ' + fmtDate(today);
}
if (kind === 'month') {
var from = new Date(today.getFullYear(), today.getMonth(), 1);
return fmtDate(from) + ' - ' + fmtDate(today);
}
return fmtDate(today) + ' - ' + fmtDate(today);
}
var initialRange = rangeOf('30d');
$('#LAY-an-date-range').val(initialRange);
laydate.render({
elem: '#LAY-an-date-range',
type: 'date',
range: true,
value: initialRange,
done: function (value) {
if (value) $('#LAY-an-date-range').val(value);
}
});
function parseRange(value) {
var m = String(value || '').match(/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/);
if (!m) return null;
return { date_from: m[1], date_to: m[2], date_range: m[1] + ' - ' + m[2] };
}
function currentWhere() {
var field = form.val('LAY-an-search') || {};
var parsed = parseRange(field.date_range || $('#LAY-an-date-range').val());
if (!parsed) return null;
return Object.assign({}, field, parsed);
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fmtPct(pct) {
if (pct === undefined || pct === null) return '—';
return pct + '%';
}
function roundPct(part, total) {
return total > 0 ? Math.round(part / total * 1000) / 10 : 0;
}
function fillTags(sel, rows, countKey) {
var html = '';
(rows || []).forEach(function (r) {
html += '<span class="an-tag">' + esc(r.label || '—')
+ '(' + esc(r[countKey]) + '/' + esc(fmtPct(r.pct)) + ')</span>';
});
$(sel).html(html || '<span class="an-tags-empty">暂无</span>');
}
function topPieRows(rows, valueKey) {
var sorted = (rows || []).filter(function (r) {
return Number(r[valueKey]) > 0;
}).slice().sort(function (a, b) {
return Number(b[valueKey]) - Number(a[valueKey]);
});
if (sorted.length <= PIE_MAX) return { rows: sorted, hidden: 0 };
var head = sorted.slice(0, PIE_MAX - 1);
var rest = sorted.slice(PIE_MAX - 1);
var otherVal = 0;
var total = 0;
sorted.forEach(function (r) { total += Number(r[valueKey]) || 0; });
rest.forEach(function (r) { otherVal += Number(r[valueKey]) || 0; });
var other = { label: '其他', pct: roundPct(otherVal, total) };
other[valueKey] = otherVal;
head.push(other);
return { rows: head, hidden: rest.length };
}
function renderPie(id, hintId, rows, valueKey, seriesName) {
var el = document.getElementById(id);
if (!el || !echartsLib || !echartsLib.init) return;
var sliced = topPieRows(rows, valueKey);
var items = [];
var names = [];
sliced.rows.forEach(function (r) {
var value = Number(r[valueKey]) || 0;
if (value <= 0) return;
var name = r.label || '—';
items.push({ name: name, value: value });
names.push(name);
});
if (!charts[id]) {
charts[id] = echartsLib.init(el, layui.echartsTheme);
window.addEventListener('resize', function () { charts[id].resize(); });
}
var chart = charts[id];
chart.resize();
chart.setOption({
tooltip: { trigger: 'item', formatter: '{b}:{c}({d}%)' },
legend: {
orient: 'horizontal',
x: 'center',
y: 'bottom',
itemWidth: 10,
itemHeight: 10,
data: names
},
calculable: false,
series: [{
name: seriesName,
type: 'pie',
radius: ['48%', '70%'],
center: ['50%', '44%'],
itemStyle: {
normal: {
label: { show: false },
labelLine: { show: false }
}
},
data: items
}]
}, true);
var hint = document.getElementById(hintId);
if (hint) {
hint.textContent = sliced.hidden > 0
? ('图中前 ' + (PIE_MAX - 1) + ' 项 + 其他(另有 ' + sliced.hidden + ' 个版本)')
: '';
}
}
function paint(data) {
var s = data.survival || {};
$('#an-survival').text(s.avg_label || '—');
$('#an-survival-caption').text('区间内新增 ' + (s.devices || 0) + ' 台 · 创建到最后更新');
var sw = data.survival_wallet || {};
$('#an-survival-wallet').text(sw.avg_label || '—');
$('#an-survival-wallet-caption').text('区间内新增 ' + (sw.devices || 0) + ' 台有钱包 · 创建到最后更新');
renderPie('an-os-chart', 'an-os-hint', data.by_os, 'uv', '系统');
renderPie('an-ios-chart', 'an-ios-hint', data.by_ios_version, 'uv', 'iOS 版本');
renderPie('an-device-chart', 'an-device-hint', data.by_device_ios_version, 'count', '受控版本');
renderPie('an-visit-country-chart', 'an-visit-country-hint', data.by_visit_country, 'uv', '访问国家');
renderPie('an-device-country-chart', 'an-device-country-hint', data.by_device_country, 'count', '受控国家');
fillTags('#an-by-ios-version', data.by_ios_version, 'uv');
fillTags('#an-by-device-ios', data.by_device_ios_version, 'count');
fillTags('#an-by-visit-country', data.by_visit_country, 'uv');
fillTags('#an-by-device-country', data.by_device_country, 'count');
var iosN = (data.by_ios_version || []).length;
var deviceN = (data.by_device_ios_version || []).length;
var visitCountryN = (data.by_visit_country || []).length;
var deviceCountryN = (data.by_device_country || []).length;
$('#an-ios-count').text(iosN ? ('· ' + iosN + ' 个') : '');
$('#an-device-count').text(deviceN ? ('· ' + deviceN + ' 个') : '');
$('#an-visit-country-count').text(visitCountryN ? ('· ' + visitCountryN + ' 个') : '');
$('#an-device-country-count').text(deviceCountryN ? ('· ' + deviceCountryN + ' 个') : '');
var computed = data.computed_at ? (' · 汇总于 ' + data.computed_at) : '';
$('#an-range').text('统计区间:' + data.from + ' ~ ' + data.to + '(按日去重后加总,今日约 15 分钟刷新)' + computed);
}
function load(where) {
var query = where || parseRange(initialRange);
$.getJSON(dataUrl, query, function (res) {
if (!res || res.code !== 0) return;
paint(res.data);
});
}
function applyRangeAndQuery(rangeText) {
$('#LAY-an-date-range').val(rangeText);
var where = currentWhere();
if (!where) {
layer.msg('请选择有效的时间范围');
return;
}
load(where);
}
$('.an-date-shortcuts').on('click', '[data-range]', function () {
applyRangeAndQuery(rangeOf($(this).data('range')));
});
form.on('submit(LAY-an-search)', function () {
var where = currentWhere();
if (!where) {
layer.msg('请选择有效的时间范围');
return false;
}
load(where);
return false;
});
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
load(parseRange(initialRange));
});
</script>
@endpush