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
+1
View File
@@ -1,6 +1,7 @@
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
<br>
<a href="/rules">{{t .LangCode "site.footer.rules"}}</a> |
<a href="/stats">{{t .LangCode "site.footer.stats"}}</a> |
<!--<a href="/pixel">画板</a> | -->
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
<br>
+1 -1
View File
@@ -8,5 +8,5 @@ import (
"embed"
)
//go:embed auth base mail question user chat vote home.html about-me.html sponsor.html rules.html change-logs.html pixel.html maintenance-mode.html
//go:embed auth base mail question user chat vote home.html about-me.html sponsor.html rules.html change-logs.html pixel.html maintenance-mode.html stats.html
var FS embed.FS
-71
View File
@@ -100,36 +100,9 @@
transition: transform 0.2s ease;
}
.tb-changelog[open] summary::before { transform: rotate(90deg); }
.tb-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
text-align: center;
}
.tb-stat-num {
display: block;
font-size: var(--tb-fs-xl);
font-weight: 600;
line-height: 1.3;
color: var(--tb-text);
}
.tb-stat-label {
display: block;
margin-top: 2px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-stats-meta {
margin: 14px 0 0;
font-size: var(--tb-fs-xs);
line-height: 1.8;
color: var(--tb-text-muted);
text-align: center;
}
@media (max-width: 480px) {
.tb-hero { padding: 28px 16px 24px; }
.tb-hero-title { font-size: var(--tb-fs-xl); }
.tb-stat-num { font-size: var(--tb-fs-lg); }
}
</style>
@@ -196,50 +169,6 @@
</ul>
</details>
<div class="uk-card uk-card-default uk-card-body">
<h2 class="tb-section-title">{{t .LangCode "site.stats"}}</h2>
<div class="tb-stats">
<div>
<span class="tb-stat-num tb-num" data-stat="registered_users">{{if .SiteStats}}{{.SiteStats.RegisteredUsers}}{{else}}-{{end}}</span>
<span class="tb-stat-label">注册用户</span>
</div>
<div>
<span class="tb-stat-num tb-num" data-stat="total_questions">{{if .SiteStats}}{{.SiteStats.TotalQuestions}}{{else}}-{{end}}</span>
<span class="tb-stat-label">累计提问</span>
</div>
<div>
<span class="tb-stat-num tb-num" data-stat="answered_questions">{{if .SiteStats}}{{.SiteStats.AnsweredQuestions}}{{else}}-{{end}}</span>
<span class="tb-stat-label">已获回答</span>
</div>
</div>
<p class="tb-stats-meta">
最后提问:<span data-stat="last_question_at">{{if .SiteStats}}{{.SiteStats.LastQuestionAt}}{{else}}-{{end}}</span><br>
最后回答:<span data-stat="last_answer_at">{{if .SiteStats}}{{.SiteStats.LastAnswerAt}}{{else}}-{{end}}</span><br>
数据更新于:<span data-stat="updated_at">{{if .SiteStats}}{{.SiteStats.UpdatedAt}}{{else}}-{{end}}</span>
</p>
</div>
<script type="text/javascript">
// 统计数字保持服务端 30 分钟缓存,页面每 30 分钟拉一次最新值就地更新(不整页刷新)。
// 与原来写 textContent 整块文本的做法等价,只是改为按字段更新三张数字卡。
document.addEventListener('DOMContentLoaded', function () {
setInterval(function () {
fetch("/api/v1/site-stats")
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data;
if (!d) return;
["registered_users", "total_questions", "answered_questions",
"last_question_at", "last_answer_at", "updated_at"].forEach(function (k) {
var v = d[k];
if (v === undefined || v === null || v === "") return;
var el = document.querySelector('[data-stat="' + k + '"]');
if (el) el.textContent = v;
});
})
.catch(function () {});
}, 30 * 60 * 1000);
});
</script>
<div id="waline"></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
+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" .}}
+93
View File
@@ -181,6 +181,96 @@ func TestGenPreview(t *testing.T) {
writeQuestionOwnerPreviews(t, outDir)
writeAdminListPreviews(t, outDir)
writeVotePreviews(t, outDir)
writeStatsPreviews(t, outDir)
}
// writeStatsPreviews 全站数据页(/stats)。
// 四种组合都出:亮/暗确认令牌适配,中/英确认英文长句不会把卡片挤变形。
func writeStatsPreviews(t *testing.T, outDir string) {
t.Helper()
now := time.Now()
daily := make([]stats.DailyPoint, 0, 30)
var peak stats.DailyPoint
for i := 0; i < 30; i++ {
d := now.AddDate(0, 0, -(29 - i))
n := int64(38 + (i*11)%47 + (i%5)*13)
p := stats.DailyPoint{Date: d.Format("2006-01-02"), Label: d.Format("01-02"), N: n}
daily = append(daily, p)
if p.N >= peak.N {
peak = p
}
}
monthly := make([]stats.MonthlyPoint, 0, 12)
for i := 0; i < 12; i++ {
m := now.AddDate(0, -(11 - i), 0)
monthly = append(monthly, stats.MonthlyPoint{
Month: m.Format("2006-01"), Label: m.Format("01"),
N: int64(2100 + i*310 + (i%3)*420), Current: i == 11,
})
}
// 热力图:造出「深夜 + 周末」两个高峰,便于肉眼确认档位分得开。
var heatmap [7][24]int64
for d := 0; d < 7; d++ {
for h := 0; h < 24; h++ {
v := int64(30 + (h%12)*16)
if h >= 21 || h <= 1 {
v += 320
}
if h >= 12 && h <= 14 {
v += 90
}
if d >= 5 {
v += 120
}
heatmap[d][h] = v
}
}
ov := &stats.Overview{
RegisteredUsers: 1284, TotalQuestions: 56321, Answered: 31208,
Public: 41002, Private: 15319, Read: 40122, Unread: 16199,
Anonymous: 22110, LoggedIn: 34211,
ActiveBoxes: 412, AnsweringBoxes: 268, TotalMessages: 8842,
LastQuestionAt: "2026-10-22 23:41:07", LastAnswerAt: "2026-10-22 23:12:33",
AnswerRate: 55.4, FastReplyRate: 38.6, Total30: 2148, AvgDailyLast30: 71.6,
GrowthMoM: 12.3, HasPrevMonth: true,
TodayQuestions: 37, TodayAnswers: 21,
Daily: daily, Monthly: monthly, PeakDay: peak, DailyMax: peak.N, MonthlyMax: 6180,
BusiestWD: 6, BusiestHr: 23,
Heatmap: heatmap, HeatmapMax: 812, HeatmapDays: 180,
LengthBins: [6]int64{8200, 14200, 16800, 9200, 6100, 1821},
ReplyBins: [6]int64{4200, 7100, 8400, 6200, 3400, 1908},
DataDate: now.Format("2006-01-02"),
UpdatedAt: now.Format("2006-01-02") + " 00:07:12",
}
for _, lang := range []string{"zh-CN", "en"} {
data := map[string]interface{}{
"Title": "全站数据",
"LangCode": lang,
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}, {"Code": "en", "Name": "English"}},
"IsLogged": false,
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"CurrentURI": "/stats",
"ExternalURL": "https://box.shiroko.one",
"CaptchaType": "math",
"Limits": conf.Limits,
"ICP": "",
"Overview": stats.NewPage(lang, ov),
}
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "stats", data)
if err != nil {
t.Fatalf("render stats: %v", err)
}
writePreview(t, outDir, "preview-stats-"+lang+"-"+theme+".html", page, theme, "../static")
}
}
}
// writeQuestionItemPreviews 单题页(/_/{domain}/{qid})。
@@ -383,6 +473,9 @@ func writePreview(t *testing.T, dir, name, html, theme, staticPrefix string) {
}
}
html = strings.ReplaceAll(html, `href="/favicon.ico"`, `href="`+staticPrefix+`/favicon.ico"`)
// base.css 也要改成本地相对路径:预览页走 file://,"/static/base.css" 是绝对路径取不到,
// 于是 :root 上的设计令牌全部缺失 —— var(--tb-*) 变成无效值,卡片没边框、图表全透明。
html = strings.ReplaceAll(html, `href="/static/`, `href="`+staticPrefix+`/`)
html = strings.Replace(html, `<title>`, `<title>预览 `+time.Now().Format("15:04")+` · `, 1)
if err := os.WriteFile(filepath.Join(dir, name), []byte(html), 0o644); err != nil {
t.Fatal(err)