390 lines
13 KiB
PHP
390 lines
13 KiB
PHP
@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-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-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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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', '受控版本');
|
||
fillTags('#an-by-ios-version', data.by_ios_version, 'uv');
|
||
fillTags('#an-by-device-ios', data.by_device_ios_version, 'count');
|
||
var iosN = (data.by_ios_version || []).length;
|
||
var deviceN = (data.by_device_ios_version || []).length;
|
||
$('#an-ios-count').text(iosN ? ('· ' + iosN + ' 个') : '');
|
||
$('#an-device-count').text(deviceN ? ('· ' + deviceN + ' 个') : '');
|
||
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
|