Files
tamabox/templates/stats.html
T
tamakyi af39651734 fix(stats): 页脚时间标签改成「快照时间」,去掉重复的「数据基准日」
原来四行时间里,前两行说的是同一件事:
「数据基准日:2026-10-24」+「计算于:2026-10-24 14:05:22」。
`DataDate` 就是 `UpdatedAt` 的日期部分(同一个 now 格式化出来的),
两行并列只是把同一个值拆成粗/细两种粒度。

而且「计算于」没有主语:读者站在页脚最底部看到它,会当成「整页数据
算到这一刻」,可今日两项恰恰不在这份快照里 —— 命中缓存时
`withToday` 会用更新的读数替换它们,页面上就出现「标注 14:05、
今日提问却是 22:35 的数」。

改成三行:
  快照时间:     2026-10-24 14:05:22
  最后一条提问: ...
  最后一条回答: ...
「快照只含累计数字与图表、今日两项实时」由下面那条 cacheNote 说明,
不塞进标签里(标签越短越好扫)。词条同步:快照时间 / 快照時間 /
Snapshot: / 集計時刻。

顺带删掉 `Overview.DataDate` 字段:模板不用它之后就没有读者了,
留着它会诱使以后的人再拼出「基准日 + 计算于」这种重复行。

无 schema/接口变更,纯展示层。i18n 有词条数量对等测试,四个语言已
一起改;`go build ./...` 与 i18n / stats / template / templates /
route 测试全绿。
2026-10-24 22:39:47 +08:00

632 lines
19 KiB
HTML
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.
{{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;
}
/* 折线图 */
/* 左边一列纵轴刻度 + 右边画布:刻度文字不能放进 SVG ——
viewBox 用了 preserveAspectRatio="none" 横向拉伸填满卡片,
放在里面的文字会跟着被拉扁(窄屏上尤其明显)。 */
.tb-line-wrap {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 6px;
margin-top: 4px;
}
.tb-line-y {
position: relative;
height: 148px;
font-size: var(--tb-fs-xs);
font-variant-numeric: tabular-nums;
color: var(--tb-text-muted);
}
/* 刻度全是绝对定位的,撑不开这一列;用一份看不见的「最宽刻度」占位,
让列宽自己贴合数字长度(上限那个数位数最多),不用写死 em。 */
.tb-line-ruler {
display: block;
visibility: hidden;
}
.tb-line-tick {
position: absolute;
right: 0;
white-space: nowrap;
/* 刻度值以刻度线为中心对齐,而不是以格子顶部对齐 */
transform: translateY(-50%);
}
.tb-line {
display: block;
width: 100%;
height: 148px;
/* 折线只占上边 148px,刻度标签可能略高于画布,别被裁掉 */
overflow: visible;
}
.tb-line .tb-grid {
stroke: var(--tb-border);
stroke-width: 1;
stroke-dasharray: 3 5;
/* 同折线:viewBox 被拉伸,描边宽度必须锁像素,否则横向会被算成几倍粗 */
vector-effect: non-scaling-stroke;
}
.tb-line .tb-grid.is-base {
stroke-dasharray: none;
}
.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);
}
/* 四条时间各自成行、标签左列对齐:挤在「A:x · B:y」一行里,两组日期
靠一个间隔号分开,扫一眼分不清哪个是基准日哪个是最后提问。 */
.tb-foot-times {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
column-gap: 8px;
row-gap: 2px;
margin-bottom: 8px;
}
.tb-foot-note {
margin: 0;
}
.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>
{{ $line := LineChart .DailyValues 640 148 12 10 }}
<div class="tb-line-wrap">
<!-- 纵轴刻度:SVG 横向被拉伸,文字只能留在外面用 HTML 定位 -->
<div class="tb-line-y" aria-hidden="true">
<span class="tb-line-ruler">{{ (index $line.Ticks 0).Label }}</span>
{{ range $line.Ticks }}<span class="tb-line-tick" style="top:{{ .TopPct }}%">{{ .Label }}</span>{{ end }}
</div>
<div class="tb-line-plot">
<svg class="tb-line" viewBox="0 0 640 148" preserveAspectRatio="none" role="img"
aria-label="{{t $.LangCode "stats.trend.dailyTitle"}}">
{{ range $line.Ticks }}
<line class="tb-grid{{ if .Base }} is-base{{ end }}" x1="0" x2="640" y1="{{ .Y }}" y2="{{ .Y }}"></line>
{{ end }}
{{ if $line.HasData }}
<path class="tb-line-area" d="{{ $line.Area }}"></path>
<polyline class="tb-line-path" points="{{ $line.Points }}"></polyline>
{{ end }}
</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>
</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>
<div class="tb-dash-foot">
<div class="tb-foot-times">
<span>{{t $.LangCode "stats.foot.updated"}}</span>
<span>{{ .UpdatedAt }}</span>
<span>{{t $.LangCode "stats.foot.prevMonth"}}</span>
<span>{{ if .LastQuestionAt }}{{ .LastQuestionAt }}{{ else }}-{{ end }}</span>
<span>{{t $.LangCode "stats.foot.prevAnswer"}}</span>
<span>{{ if .LastAnswerAt }}{{ .LastAnswerAt }}{{ else }}-{{ end }}</span>
</div>
<p class="tb-foot-note">{{t $.LangCode "stats.foot.cacheNote"}}</p>
<p class="tb-foot-note">{{t $.LangCode "stats.foot.privacy"}}</p>
</div>
</div>
{{ else }}
<div class="tb-dash-empty">{{t .LangCode "stats.unavailable"}}</div>
{{ end }}
{{template "base/footer" .}}