Files
coruna-lab/resources/views/admin/reports/analytics.blade.php
T
2026-09-14 02:54:57 +08:00

390 lines
13 KiB
PHP
Raw 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-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, '&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', '受控版本');
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