Files
tamabox/templates/stats.html
T
tamakyi 64436d9d61 fix(stats): 数据速览用完整星期名,脚注时间改逐行对齐
- 「提问最集中在 三 19 点前后」:洞察复用了热力图列头的短标签
  stats.wd.*(一…日 / Mon…),那是为窄列头设计的单字词条,
  进句子就成了「三」。新增 stats.weekday.1..7 完整名(周一 / Monday /
  月曜日),洞察改用该组,stats.wd.* 只留给列头;补测试断言锁住。
- 页脚入口文案「数据」→「统计数据」(四语言同步)。
- 页面脚注四条时间由「基准日 x · 计算于 y」一行改为两列网格逐行排列,
  标签统一带冒号,下面两段说明保持独立段落。
2026-10-24 01:08:50 +08:00

581 lines
18 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;
}
/* 折线图 */
.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);
}
/* 四条时间各自成行、标签左列对齐:挤在「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>
<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>
<div class="tb-dash-foot">
<div class="tb-foot-times">
<span>{{t $.LangCode "stats.foot.dataDate"}}</span>
<span>{{ .DataDate }}</span>
<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" .}}