feat(stats): 新增公开数据页 /stats,入口收敛到页脚

首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。

- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
This commit is contained in:
tamakyi
2026-10-24 00:57:08 +08:00
parent c680671b7e
commit a5441c99fd
25 changed files with 2280 additions and 80 deletions
+561
View File
@@ -0,0 +1,561 @@
{{template "base/header" .}}
<style>
/* 全站数据页:只用令牌与 CSS 画图(折线 SVG、柱状/分布条用百分比高度、环形用 pathLength、
热力图用 grid + 主色透明度)。不引图表库,页面在禁用 JS 时也完整。 */
.tb-dash {
font-variant-numeric: tabular-nums;
}
.tb-dash-head {
padding: 4px 2px 14px;
}
.tb-dash-head h2 {
margin: 0;
font-size: var(--tb-fs-xl);
font-weight: 600;
color: var(--tb-text);
}
.tb-dash-head p {
margin: 8px 0 0;
font-size: var(--tb-fs-md);
line-height: 1.7;
color: var(--tb-text-muted);
}
/* 今日条 */
.tb-today {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 18px;
padding: 12px 16px;
margin-bottom: 12px;
background: var(--tb-primary-weak);
border-radius: var(--tb-radius-sm);
font-size: var(--tb-fs-md);
color: var(--tb-text-secondary);
}
.tb-today b {
font-size: var(--tb-fs-lg);
color: var(--tb-primary);
font-weight: 700;
}
.tb-today .tb-today-tag {
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
/* 卡片 */
.tb-card {
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius);
padding: 18px 18px 20px;
margin-bottom: 12px;
}
.tb-card-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin: 0 0 4px;
font-size: var(--tb-fs-lg);
font-weight: 600;
color: var(--tb-text);
}
.tb-card-note {
margin: 0 0 16px;
font-size: var(--tb-fs-xs);
line-height: 1.6;
color: var(--tb-text-muted);
}
/* KPI 网格 */
.tb-kpis {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 12px;
}
@media (min-width: 560px) {
.tb-kpis { grid-template-columns: repeat(3, 1fr); }
}
.tb-kpi {
padding: 14px 12px;
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
}
.tb-kpi-num {
display: block;
font-size: 1.375rem;
line-height: 1.2;
font-weight: 700;
color: var(--tb-text);
}
.tb-kpi-num.tb-kpi-accent { color: var(--tb-primary); }
.tb-kpi-lbl {
display: block;
margin-top: 4px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
/* 洞察 */
.tb-insights {
margin: 0;
padding: 0;
list-style: none;
}
.tb-insights li {
position: relative;
padding: 6px 0 6px 18px;
font-size: var(--tb-fs-md);
line-height: 1.7;
color: var(--tb-text-secondary);
}
.tb-insights li::before {
content: "";
position: absolute;
left: 4px;
top: 14px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--tb-primary);
opacity: .55;
}
/* 折线图 */
.tb-line-wrap { margin-top: 4px; }
.tb-line {
display: block;
width: 100%;
height: 148px;
}
.tb-line .tb-line-area {
/* 比 --tb-primary-weak(0.10)略深一档:折线下方的面积要能看出「有」,但不能抢走线条本身 */
fill: var(--tb-primary);
fill-opacity: .14;
stroke: none;
}
.tb-line .tb-line-path {
fill: none;
stroke: var(--tb-primary);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
/* viewBox 与显示宽度不一致,必须锁定描边像素,否则窄屏上线会被压细或拉粗 */
vector-effect: non-scaling-stroke;
}
.tb-axis {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
/* 月度柱状 */
.tb-bars {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 5px;
align-items: end;
}
.tb-bar-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}
.tb-bar-track {
display: flex;
align-items: flex-end;
width: 100%;
height: 108px;
}
.tb-bar-track i {
display: block;
width: 100%;
min-height: 2px;
border-radius: 3px 3px 0 0;
background: var(--tb-primary);
opacity: .82;
}
.tb-bar-col:hover .tb-bar-track i { opacity: 1; }
.tb-bar-lbl {
margin-top: 6px;
font-size: 0.6875rem;
color: var(--tb-text-muted);
}
.tb-bar-col.is-current .tb-bar-lbl { color: var(--tb-primary); font-weight: 600; }
/* 构成环 */
.tb-donuts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.tb-donut {
text-align: center;
}
.tb-donut-ring {
position: relative;
width: 76px;
margin: 0 auto;
}
.tb-donut-ring svg {
display: block;
width: 100%;
height: auto;
}
.tb-donut-ring .tb-ring-track {
fill: none;
stroke: var(--tb-surface-2);
stroke-width: 6;
}
.tb-donut-ring .tb-ring-val {
fill: none;
stroke: var(--tb-primary);
stroke-width: 6;
stroke-linecap: round;
}
.tb-donut-pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--tb-fs-md);
font-weight: 700;
color: var(--tb-text);
}
.tb-donut-cap {
margin-top: 8px;
font-size: var(--tb-fs-xs);
line-height: 1.5;
color: var(--tb-text-muted);
}
.tb-donut-cap b {
display: block;
margin-top: 2px;
color: var(--tb-text-secondary);
font-weight: 600;
}
/* 热力图 */
.tb-heat {
/* 24 列在 750px 容器里会把方块撑到 26px 见方,整块图比上面的折线还高。
限宽后每格约 20px,既看得清又不至于把页面拉得太长。 */
max-width: 620px;
margin: 0 auto;
}
.tb-heat-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.tb-heat-lbl {
flex: 0 0 26px;
font-size: 0.6875rem;
color: var(--tb-text-muted);
text-align: center;
}
.tb-heat-cells {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 2px;
flex: 1 1 auto;
}
.tb-heat-cells i {
display: block;
aspect-ratio: 1 / 1;
border-radius: 2px;
background: var(--tb-surface-2);
}
.tb-heat-cells i.lv1 { background: var(--tb-primary); opacity: .18; }
.tb-heat-cells i.lv2 { background: var(--tb-primary); opacity: .38; }
.tb-heat-cells i.lv3 { background: var(--tb-primary); opacity: .62; }
.tb-heat-cells i.lv4 { background: var(--tb-primary); opacity: 1; }
.tb-heat-hours {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 2px;
margin: 6px 0 0 34px;
font-size: 0.625rem;
color: var(--tb-text-muted);
}
.tb-heat-hours span { text-align: center; }
.tb-heat-legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: 10px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-heat-legend i {
display: block;
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--tb-surface-2);
}
.tb-heat-legend i.lv1 { background: var(--tb-primary); opacity: .18; }
.tb-heat-legend i.lv2 { background: var(--tb-primary); opacity: .38; }
.tb-heat-legend i.lv3 { background: var(--tb-primary); opacity: .62; }
.tb-heat-legend i.lv4 { background: var(--tb-primary); opacity: 1; }
/* 分布条 */
.tb-dist + .tb-dist { margin-top: 20px; }
.tb-dist-title {
margin: 0 0 10px;
font-size: var(--tb-fs-md);
font-weight: 600;
color: var(--tb-text-secondary);
}
.tb-dist-row {
/* 标签列用 em 而不是 px:英文桶名("100–199 chars")比中文长一截,
写死 84px 会让它折成两行,把整组分布条的高度带偏。 */
display: grid;
grid-template-columns: 7.5em 1fr 3.6em;
gap: 10px;
align-items: center;
margin-bottom: 6px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-dist-track {
height: 8px;
border-radius: 4px;
background: var(--tb-surface-2);
overflow: hidden;
}
.tb-dist-track i {
display: block;
height: 100%;
min-width: 2px;
border-radius: 4px;
background: var(--tb-primary);
opacity: .78;
}
.tb-dist-num { text-align: right; }
.tb-dash-foot {
margin: 4px 2px 0;
font-size: var(--tb-fs-xs);
line-height: 1.8;
color: var(--tb-text-muted);
}
.tb-dash-empty {
padding: 40px 16px;
text-align: center;
font-size: var(--tb-fs-md);
color: var(--tb-text-muted);
}
</style>
{{ with .Overview }}
{{ $ov := . }}
<div class="tb-dash">
<div class="tb-dash-head">
<h2>{{t $.LangCode "stats.title"}}</h2>
<p>{{t $.LangCode "stats.lead"}}</p>
</div>
<!-- 今日实时:不参与「每天一次」的缓存,每次打开都是当下数字 -->
<div class="tb-today">
<span>{{t $.LangCode "stats.today.questions"}} <b>{{NumInt .TodayQuestions}}</b></span>
<span>{{t $.LangCode "stats.today.answers"}} <b>{{NumInt .TodayAnswers}}</b></span>
<span class="tb-today-tag">{{t $.LangCode "stats.today.tag"}}</span>
</div>
<!-- 累计概览 -->
<div class="tb-kpis">
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .RegisteredUsers}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.users"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .TotalQuestions}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.questions"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .Answered}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.answered"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num tb-kpi-accent">{{PctText .Answered .TotalQuestions}}%</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.answerRate"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .ActiveBoxes}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.activeBoxes"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .TotalMessages}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.messages"}}</span>
</div>
</div>
<!-- 文字结论 -->
{{ if .Insights }}
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.insight.title"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.insight.note"}}</p>
<ul class="tb-insights">
{{ range .Insights }}<li>{{ . }}</li>{{ end }}
</ul>
</div>
{{ end }}
<!-- 近 30 天趋势 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.trend.dailyTitle"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.trend.dailyNote"}}</p>
<div class="tb-line-wrap">
<svg class="tb-line" viewBox="0 0 640 148" preserveAspectRatio="none" role="img"
aria-label="{{t $.LangCode "stats.trend.dailyTitle"}}">
<path class="tb-line-area" d="{{ AreaPath .DailyValues 640 148 12 10 }}"></path>
<polyline class="tb-line-path" points="{{ Polyline .DailyValues 640 148 12 10 }}"></polyline>
</svg>
{{ if eq (len .Daily) 30 }}
<div class="tb-axis">
<span>{{ (index .Daily 0).Label }}</span>
<span>{{ (index .Daily 15).Label }}</span>
<span>{{ (index .Daily 29).Label }}</span>
</div>
{{ end }}
</div>
</div>
<!-- 近 12 个月 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.trend.monthlyTitle"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.trend.monthlyNote"}}</p>
<div class="tb-bars">
{{ range $i, $p := .Monthly }}
<div class="tb-bar-col{{ if $p.Current }} is-current{{ end }}" title="{{ $p.Month }} · {{ NumInt $p.N }}">
<div class="tb-bar-track"><i style="height:{{ printf "%.1f" (index $ov.MonthlyPcts $i) }}%"></i></div>
<span class="tb-bar-lbl">{{ $p.Label }}</span>
</div>
{{ end }}
</div>
</div>
<!-- 构成 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.mix.title"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.mix.note"}}</p>
<div class="tb-donuts">
<div class="tb-donut">
<div class="tb-donut-ring">
<svg viewBox="0 0 42 42" role="img" aria-label="{{t $.LangCode "stats.mix.public"}}">
<circle class="tb-ring-track" cx="21" cy="21" r="15.915" pathLength="100"></circle>
<circle class="tb-ring-val" cx="21" cy="21" r="15.915" pathLength="100"
stroke-dasharray="{{ PctText .Public .TotalQuestions }} 100"
transform="rotate(-90 21 21)"></circle>
</svg>
<span class="tb-donut-pct">{{ PctText .Public .TotalQuestions }}%</span>
</div>
<div class="tb-donut-cap">{{t $.LangCode "stats.mix.public"}}<b>{{ NumInt .Public }}</b></div>
</div>
<div class="tb-donut">
<div class="tb-donut-ring">
<svg viewBox="0 0 42 42" role="img" aria-label="{{t $.LangCode "stats.mix.answered"}}">
<circle class="tb-ring-track" cx="21" cy="21" r="15.915" pathLength="100"></circle>
<circle class="tb-ring-val" cx="21" cy="21" r="15.915" pathLength="100"
stroke-dasharray="{{ PctText .Answered .TotalQuestions }} 100"
transform="rotate(-90 21 21)"></circle>
</svg>
<span class="tb-donut-pct">{{ PctText .Answered .TotalQuestions }}%</span>
</div>
<div class="tb-donut-cap">{{t $.LangCode "stats.mix.answered"}}<b>{{ NumInt .Answered }}</b></div>
</div>
<div class="tb-donut">
<div class="tb-donut-ring">
<svg viewBox="0 0 42 42" role="img" aria-label="{{t $.LangCode "stats.mix.login"}}">
<circle class="tb-ring-track" cx="21" cy="21" r="15.915" pathLength="100"></circle>
<circle class="tb-ring-val" cx="21" cy="21" r="15.915" pathLength="100"
stroke-dasharray="{{ PctText .LoggedIn .TotalQuestions }} 100"
transform="rotate(-90 21 21)"></circle>
</svg>
<span class="tb-donut-pct">{{ PctText .LoggedIn .TotalQuestions }}%</span>
</div>
<div class="tb-donut-cap">{{t $.LangCode "stats.mix.login"}}<b>{{ NumInt .LoggedIn }}</b></div>
</div>
</div>
</div>
<!-- 时段热力图 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.heat.title"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.heat.note" .HeatmapDays}}</p>
<div class="tb-heat">
{{ range $i, $row := .HeatmapRows }}
<div class="tb-heat-row">
<span class="tb-heat-lbl">{{ index $ov.WeekdayLabels $i }}</span>
<div class="tb-heat-cells">
{{ range $row }}<i class="lv{{ HeatLevel . $ov.HeatmapMax }}" title="{{ . }}"></i>{{ end }}
</div>
</div>
{{ end }}
<div class="tb-heat-hours">
<span>0</span><span></span><span></span><span></span><span></span><span></span><span>6</span>
<span></span><span></span><span></span><span></span><span></span><span>12</span>
<span></span><span></span><span></span><span></span><span></span><span>18</span>
<span></span><span></span><span></span><span></span><span>23</span>
</div>
<div class="tb-heat-legend">
<span>{{t $.LangCode "stats.heat.less"}}</span>
<i></i><i class="lv1"></i><i class="lv2"></i><i class="lv3"></i><i class="lv4"></i>
<span>{{t $.LangCode "stats.heat.more"}}</span>
</div>
</div>
</div>
<!-- 内容与节奏 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.content.title"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.content.note"}}</p>
<div class="tb-dist">
<h4 class="tb-dist-title">{{t $.LangCode "stats.content.lengthTitle"}}</h4>
{{ range $i, $n := .LengthBins }}
<div class="tb-dist-row">
<span>{{ index $ov.LengthLabels $i }}</span>
<div class="tb-dist-track"><i style="width:{{ printf "%.1f" (index $ov.LengthPcts $i) }}%"></i></div>
<span class="tb-dist-num">{{ NumInt $n }}</span>
</div>
{{ end }}
</div>
<div class="tb-dist">
<h4 class="tb-dist-title">{{t $.LangCode "stats.content.replyTitle"}}</h4>
{{ range $i, $n := .ReplyBins }}
<div class="tb-dist-row">
<span>{{ index $ov.ReplyLabels $i }}</span>
<div class="tb-dist-track"><i style="width:{{ printf "%.1f" (index $ov.ReplyPcts $i) }}%"></i></div>
<span class="tb-dist-num">{{ NumInt $n }}</span>
</div>
{{ end }}
</div>
</div>
<p class="tb-dash-foot">
{{t $.LangCode "stats.foot.dataDate"}} {{ .DataDate }} · {{t $.LangCode "stats.foot.updated"}} {{ .UpdatedAt }}
<br>{{t $.LangCode "stats.foot.prevMonth"}}{{ if .LastQuestionAt }}{{ .LastQuestionAt }}{{ else }}-{{ end }}
· {{t $.LangCode "stats.foot.prevAnswer"}}{{ if .LastAnswerAt }}{{ .LastAnswerAt }}{{ else }}-{{ end }}
<br>{{t $.LangCode "stats.foot.cacheNote"}}
<br>{{t $.LangCode "stats.foot.privacy"}}
</p>
</div>
{{ else }}
<div class="tb-dash-empty">{{t .LangCode "stats.unavailable"}}</div>
{{ end }}
{{template "base/footer" .}}