feat(plugins): 4 个业务插件代码 + 前台公开页面改造
plugins/navManager:导航 CRUD + 导航分类 CRUD(独立 blueprint) plugins/friendManager:友情链接 CRUD plugins/aboutManager:关于本站 CRUD plugins/siteStats:访问统计主页 + JSON 接口 + /site/* GET 埋点 + nav 卡片点击埋点 applications/view/public/nav.py:导航聚合页 / 分类详情 / JSON 接口,支持 visit_count 注入 applications/view/public/about.py:关于本站公开页 applications/view/public/friend.py:友情链接公开页 templates/public/base.html:响应式首页(搜索框 / 分类抽屉 / 5 主题切换 / footer 隐私小字) templates/public/index.html / category.html:卡片右下角访问次数 templates/public/about.html / friend.html:关于 + 友链前台页 docs/plugins-development.md:插件开发完整指南(生命周期 + 4 个示例 + FAQ + framework 迁移) scripts/:探针与端到端验证脚本(probe_*/verify_*/test_*)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_name": "访问统计",
|
||||
"plugin_version": "1.0.0",
|
||||
"plugin_description": "后台访问统计主页 + 汇总/按天/路径接口 + before_request 全站埋点。前台 /site/* GET 自动累计 PV/UV。"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
访问统计插件入口
|
||||
|
||||
- event_init:在 app 上注册 /system/stats/* 蓝图
|
||||
- event_finish:挂 before_request 埋点
|
||||
"""
|
||||
from flask import Flask
|
||||
|
||||
from .view.stat import bp, site_bp, record_visit
|
||||
|
||||
|
||||
def event_init(app: Flask):
|
||||
app.register_blueprint(bp)
|
||||
app.register_blueprint(site_bp)
|
||||
print(" * siteStats: registered /system/stats/* and /site/nav/*click blueprints")
|
||||
|
||||
|
||||
def event_finish(app: Flask):
|
||||
@app.before_request
|
||||
def _stat_record():
|
||||
record_visit()
|
||||
print(" * siteStats: registered /site/* before_request hook")
|
||||
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>访问统计</title>
|
||||
{% include 'system/common/header.html' %}
|
||||
</head>
|
||||
<body class="pear-container">
|
||||
|
||||
<div class="layui-row layui-col-space16" style="margin-top:12px;">
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body layui-text-center">
|
||||
<div style="font-size:32px;color:var(--brand);font-weight:600;" id="stat-total-pv">--</div>
|
||||
<div style="color:#888;margin-top:6px;">累计 PV(页面浏览)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body layui-text-center">
|
||||
<div style="font-size:32px;color:#5fb878;font-weight:600;" id="stat-total-uv">--</div>
|
||||
<div style="color:#888;margin-top:6px;">累计 UV(独立访客)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body layui-text-center">
|
||||
<div style="font-size:32px;color:#ffb800;font-weight:600;" id="stat-today-pv">--</div>
|
||||
<div style="color:#888;margin-top:6px;">今日 PV</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md3">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-body layui-text-center">
|
||||
<div style="font-size:32px;color:#ff5722;font-weight:600;" id="stat-today-uv">--</div>
|
||||
<div style="color:#888;margin-top:6px;">今日 UV</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-card" style="margin-top:16px;">
|
||||
<div class="layui-card-header">
|
||||
<i class="layui-icon layui-icon-template-1"></i> 最近 7 天趋势
|
||||
<select id="days-select" style="float:right;font-size:13px;height:32px;border-radius:4px;padding:0 8px;border:1px solid #ddd;">
|
||||
<option value="7">近 7 天</option>
|
||||
<option value="14">近 14 天</option>
|
||||
<option value="30">近 30 天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<div id="chart-days" style="height:280px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layui-row layui-col-space16" style="margin-top:16px;">
|
||||
<div class="layui-col-md7">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"><i class="layui-icon layui-icon-link"></i> 路径 TOP10</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-skin="line">
|
||||
<thead>
|
||||
<tr><th>路径</th><th width="100">PV</th><th width="200">最近访问</th></tr>
|
||||
</thead>
|
||||
<tbody id="paths-tbody">
|
||||
<tr><td colspan="3" class="layui-text-center" style="color:#999;">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-col-md5">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header"><i class="layui-icon layui-icon-time"></i> 最近访问</div>
|
||||
<div class="layui-card-body">
|
||||
<table class="layui-table" lay-skin="line">
|
||||
<thead>
|
||||
<tr><th>路径</th><th width="120">IP</th><th width="160">时间</th></tr>
|
||||
</thead>
|
||||
<tbody id="latest-tbody">
|
||||
<tr><td colspan="3" class="layui-text-center" style="color:#999;">加载中…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
{% include 'system/common/footer.html' %}
|
||||
<script src="{{ url_for('static', filename='system/component/pear/module/extends/echarts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='system/component/pear/module/extends/echartsTheme.js') }}"></script>
|
||||
<script>
|
||||
layui.use(['element', 'jquery'], function(){
|
||||
var $ = layui.$;
|
||||
var daysChart = echarts.init(document.getElementById('chart-days'));
|
||||
|
||||
function loadSummary(){
|
||||
$.get('/system/stats/summary', function(res){
|
||||
if (res.code === 0){
|
||||
$('#stat-total-pv').text(res.data.pv_total);
|
||||
$('#stat-total-uv').text(res.data.uv_total);
|
||||
$('#stat-today-pv').text(res.data.today_pv);
|
||||
$('#stat-today-uv').text(res.data.today_uv);
|
||||
var rows = res.data.latest || [];
|
||||
var html = '';
|
||||
if (rows.length === 0){
|
||||
html = '<tr><td colspan="3" class="layui-text-center" style="color:#999;">暂无访问</td></tr>';
|
||||
} else {
|
||||
rows.forEach(function(r){
|
||||
html += '<tr><td>' + (r.path || '-') + '</td><td>' + (r.ip || '-') + '</td><td>' + (r.visit_at || '-') + '</td></tr>';
|
||||
});
|
||||
}
|
||||
$('#latest-tbody').html(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadDays(n){
|
||||
$.get('/system/stats/days?days=' + n, function(res){
|
||||
if (res.code === 0){
|
||||
var data = res.data || [];
|
||||
daysChart.setOption({
|
||||
tooltip: {trigger: 'axis'},
|
||||
legend: {data: ['PV', 'UV']},
|
||||
xAxis: {type: 'category', data: data.map(function(d){return d.day.slice(5);})},
|
||||
yAxis: {type: 'value'},
|
||||
series: [
|
||||
{name: 'PV', type: 'line', smooth: true, data: data.map(function(d){return d.pv;})},
|
||||
{name: 'UV', type: 'line', smooth: true, data: data.map(function(d){return d.uv;})}
|
||||
]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadPaths(){
|
||||
$.get('/system/stats/paths', function(res){
|
||||
if (res.code === 0){
|
||||
var rows = res.data || [];
|
||||
var html = '';
|
||||
if (rows.length === 0){
|
||||
html = '<tr><td colspan="3" class="layui-text-center" style="color:#999;">暂无数据</td></tr>';
|
||||
} else {
|
||||
rows.forEach(function(r){
|
||||
html += '<tr><td>' + (r.path || '-') + '</td><td><b>' + (r.pv || 0) + '</b></td><td>' + (r.last_hit || '-') + '</td></tr>';
|
||||
});
|
||||
}
|
||||
$('#paths-tbody').html(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#days-select').on('change', function(){
|
||||
loadDays(parseInt($(this).val(), 10));
|
||||
});
|
||||
|
||||
loadSummary();
|
||||
loadDays(7);
|
||||
loadPaths();
|
||||
|
||||
window.addEventListener('resize', function(){ daysChart.resize(); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
访问统计插件:后台统计主页 + 3 个 JSON 接口 + 全站 before_request 埋点。
|
||||
|
||||
URL 前缀:/system/stats (与框架原路径一致);埋点用 before_request 挂在框架 app 上。
|
||||
"""
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
from applications.common.utils.rights import authorize
|
||||
from applications.extensions import db
|
||||
from applications.models import VisitLog, PageStat, NavClick
|
||||
|
||||
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||
bp = Blueprint(
|
||||
'site_stats', __name__,
|
||||
url_prefix='/system/stats',
|
||||
template_folder=os.path.join(dir_path, '..', 'templates'),
|
||||
)
|
||||
|
||||
# 公开埋点 blueprint:无 url_prefix,路径就是 /site/nav/<id>/click
|
||||
site_bp = Blueprint(
|
||||
'site_stats_public', __name__,
|
||||
template_folder=os.path.join(dir_path, '..', 'templates'),
|
||||
)
|
||||
|
||||
|
||||
@bp.get('/')
|
||||
@authorize("site:stats:main")
|
||||
def main():
|
||||
return render_template('system/stat/main.html')
|
||||
|
||||
|
||||
@bp.get('/summary')
|
||||
@authorize("site:stats:main")
|
||||
def summary():
|
||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||
pv_total = db.session.query(db.func.coalesce(db.func.sum(PageStat.pv), 0)).scalar() or 0
|
||||
today_pv = (db.session.query(db.func.coalesce(db.func.sum(PageStat.pv), 0))
|
||||
.filter(PageStat.day == today).scalar()) or 0
|
||||
|
||||
today_uv_set = set()
|
||||
for r in PageStat.query.filter(PageStat.day == today, PageStat.uv_ip_set != '').all():
|
||||
for ip in (r.uv_ip_set or '').split(','):
|
||||
ip = ip.strip()
|
||||
if ip:
|
||||
today_uv_set.add(ip)
|
||||
today_uv = len(today_uv_set)
|
||||
|
||||
all_uv_set = set()
|
||||
for r in PageStat.query.filter(PageStat.uv_ip_set != '').all():
|
||||
for ip in (r.uv_ip_set or '').split(','):
|
||||
ip = ip.strip()
|
||||
if ip:
|
||||
all_uv_set.add(ip)
|
||||
uv_all = len(all_uv_set)
|
||||
|
||||
latest = VisitLog.query.order_by(VisitLog.visit_at.desc()).limit(5).all()
|
||||
latest_rows = [{
|
||||
'path': r.path,
|
||||
'ip': r.ip or '-',
|
||||
'visit_at': r.visit_at.strftime('%Y-%m-%d %H:%M:%S') if r.visit_at else '',
|
||||
} for r in latest]
|
||||
|
||||
return jsonify({
|
||||
'code': 0,
|
||||
'msg': '请求成功',
|
||||
'data': {
|
||||
'pv_total': int(pv_total),
|
||||
'uv_total': uv_all,
|
||||
'today_pv': int(today_pv),
|
||||
'today_uv': today_uv,
|
||||
'latest': latest_rows,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@bp.get('/days')
|
||||
@authorize("site:stats:main")
|
||||
def days():
|
||||
try:
|
||||
days = max(1, min(30, int(request.args.get('days', 7))))
|
||||
except (TypeError, ValueError):
|
||||
days = 7
|
||||
cutoff = datetime.date.today() - datetime.timedelta(days=days - 1)
|
||||
cutoff_str = cutoff.strftime('%Y-%m-%d')
|
||||
|
||||
rows = PageStat.query.filter(PageStat.day >= cutoff_str).all()
|
||||
pv_by_day = defaultdict(int)
|
||||
uv_by_day = defaultdict(set)
|
||||
for r in rows:
|
||||
pv_by_day[r.day] += r.pv
|
||||
for ip in (r.uv_ip_set or '').split(','):
|
||||
ip = ip.strip()
|
||||
if ip:
|
||||
uv_by_day[r.day].add(ip)
|
||||
|
||||
out = []
|
||||
for i in range(days):
|
||||
d = (cutoff + datetime.timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
out.append({
|
||||
'day': d,
|
||||
'pv': pv_by_day.get(d, 0),
|
||||
'uv': len(uv_by_day.get(d, set())),
|
||||
})
|
||||
return jsonify({'code': 0, 'msg': '请求成功', 'data': out})
|
||||
|
||||
|
||||
@bp.get('/paths')
|
||||
@authorize("site:stats:main")
|
||||
def paths():
|
||||
rows = PageStat.query.filter(PageStat.path != '').order_by(PageStat.pv.desc()).limit(10).all()
|
||||
out = []
|
||||
for r in rows:
|
||||
out.append({
|
||||
'path': r.path,
|
||||
'pv': r.pv or 0,
|
||||
'last_hit': r.last_hit.strftime('%Y-%m-%d %H:%M:%S') if r.last_hit else '',
|
||||
})
|
||||
return jsonify({'code': 0, 'msg': '请求成功', 'data': out})
|
||||
|
||||
|
||||
def record_visit():
|
||||
"""before_request 钩子调用:埋点 /site/* GET。"""
|
||||
if not request.path.startswith('/site'):
|
||||
return
|
||||
if request.method != 'GET':
|
||||
return
|
||||
path = request.path
|
||||
day = datetime.date.today().strftime('%Y-%m-%d')
|
||||
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.remote_addr or '')
|
||||
ua_short = (request.user_agent.string or '')[:255]
|
||||
referer = request.headers.get('Referer', '')[:255]
|
||||
|
||||
try:
|
||||
log = VisitLog(path=path, referer=referer, ua=ua_short, ip=ip, day=day)
|
||||
db.session.add(log)
|
||||
stat = PageStat.query.filter_by(path=path, day=day).first()
|
||||
if stat is None:
|
||||
stat = PageStat(path=path, day=day, pv=1, uv=1 if ip else 0,
|
||||
uv_ip_set=(ip + ',') if ip else '',
|
||||
last_hit=datetime.datetime.now())
|
||||
db.session.add(stat)
|
||||
else:
|
||||
stat.pv = (stat.pv or 0) + 1
|
||||
stat.last_hit = datetime.datetime.now()
|
||||
existing = (stat.uv_ip_set or '').split(',')
|
||||
existing = [x.strip() for x in existing if x.strip()]
|
||||
if ip and ip not in existing:
|
||||
existing.append(ip)
|
||||
stat.uv_ip_set = ','.join(existing) + ','
|
||||
stat.uv = len(existing)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
# ---------- 导航卡片点击埋点 ----------
|
||||
|
||||
# 进程级内存缓存,避免每个聚合页请求都 GROUP BY 一次
|
||||
# 结构:{nav_id: count}
|
||||
_visit_count_cache = {}
|
||||
_visit_count_cache_at = 0.0
|
||||
_VISIT_COUNT_TTL = 60.0 # 秒
|
||||
|
||||
|
||||
def get_nav_visit_counts():
|
||||
"""返回 {nav_id: total_clicks},60 秒内走缓存。"""
|
||||
global _visit_count_cache, _visit_count_cache_at
|
||||
now = time.time()
|
||||
if _visit_count_cache and (now - _visit_count_cache_at) < _VISIT_COUNT_TTL:
|
||||
return _visit_count_cache
|
||||
rows = db.session.query(
|
||||
NavClick.nav_id,
|
||||
db.func.count(NavClick.id),
|
||||
).group_by(NavClick.nav_id).all()
|
||||
counts = {int(nid): int(c) for nid, c in rows}
|
||||
_visit_count_cache = counts
|
||||
_visit_count_cache_at = now
|
||||
return counts
|
||||
|
||||
|
||||
@site_bp.post('/site/nav/<int:nav_id>/click')
|
||||
def nav_click(nav_id):
|
||||
"""导航卡片点击埋点:JS sendBeacon 调用,返回 204 无副作用。"""
|
||||
try:
|
||||
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.remote_addr or '')
|
||||
day = datetime.date.today().strftime('%Y-%m-%d')
|
||||
db.session.add(NavClick(nav_id=nav_id, day=day, ip=ip[:64]))
|
||||
db.session.commit()
|
||||
# 让缓存尽快失效(最多延迟 60s 也无所谓,统计本来就不需要实时)
|
||||
global _visit_count_cache_at
|
||||
_visit_count_cache_at = 0.0
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
# 204 No Content:无响应体,beacon 友好
|
||||
return ('', 204)
|
||||
Reference in New Issue
Block a user